Migrating foniolabs.xyz to Sanity + Next.js in 14 days
How I rebuilt a static-export marketing site as a Sanity-driven Next.js 16 app on Vercel, with custom Studio components, GROQ-driven ISR, and a Claude Code + Sanity MCP integration the marketing team actually uses.
The brief
foniolabs.xyz was a static-exported Next.js site on Hostinger. Every content change required a developer to edit JSX, build, and rsync the output over SSH. The marketing team was effectively locked out.
The brief was twofold. Operationally: migrate to a CMS the marketing team can actually use, without breaking the live site or losing SEO ranking. Strategically: produce a portfolio-grade artifact for an OpenZeppelin Senior Web Developer (Contractor) application — the JD explicitly asked for proven Sanity expertise, a complex CMS migration end-to-end, and interactive elements like live stats. Sprint window: 14 calendar days, ~45 working hours.
By the numbers
From the deployed Sanity manifest + the production Vercel build.
What I built
Surface-by-surface. Each item is grep-jumpable in the public repo.
Composable content model
3 reusable objects (seo, socialLinks, ctaLink), 9 section blocks (hero, featureGrid, richText, cta, testimonial, logoCloud, embedHtml, contactForm, liveStats), 8 documents. Every page composes from blocks — marketing drops a section in Studio, the renderer picks it up automatically.
Custom Studio components
SlugInput auto-creates a 301 redirect when a published slug is renamed (so editor mistakes never break SEO). VariantChipInput swaps dropdowns for visual chips. SEOPreview renders a live Google SERP snippet. OGImagePreview renders the /api/og card inline.
GROQ + ISR data layer
Single fetch wrapper picks published vs draft client based on draftMode(). Every query carries a cache tag; on Sanity publish, a signed webhook hits /api/revalidate and invalidates the right tags via revalidateTag(). Pages re-render in seconds without a redeploy.
Draft mode + Presentation tool
Editors hit "Preview" in Studio and land on the live site with their unpublished changes rendering inline. A draft-mode banner makes the state visible. Per-doc preview URLs are signed against a read token.
SEO continuity
Sitemap pulled from Sanity slugs at request time. JSON-LD (Organization, Article, Breadcrumb) on every relevant route. Build-time Next.js redirects() reads Sanity redirect docs so the auto-redirects from SlugInput actually fire in production.
Performance pass
urlFor() emits AVIF/WebP via Sanity's CDN. LCP candidates get priority + sizes. Server-component-first rendering keeps the client bundle small. ISR with tag-based revalidation means published content is fresh without serving every request from origin.
Stack
- CMS
- Sanity v5 (embedded Studio at /studio)
- Framework
- Next.js 16 App Router · RSC · Turbopack
- Language
- TypeScript
- Styling
- Tailwind CSS v4
- Hosting
- Vercel · region iad1 · git integration
- Email / forms
- HubSpot Forms (region-aware) · Resend
- Analytics
- GA4 via @next/third-parties
- AI integration
- Sanity MCP at mcp.sanity.io · OAuth-scoped roles
Claude Code + Sanity MCP
The OpenZeppelin JD specifically asks for AI-native fluency. I shipped a real Claude Code + Sanity MCP integration the marketing team uses to edit content from natural-language prompts.
- Per-project .mcp.json at the repo root registers the hosted Sanity MCP server (mcp.sanity.io). Anyone who clones gets the integration for free — no per-machine setup.
- OAuth-based — no token committed to the repo. Each operator's Sanity role gates what they can edit.
- OPERATING.md walks a non-technical marketer through their first 30 minutes: install, OAuth, prompt cheat-sheet, when NOT to use MCP, troubleshooting.
- The four canonical prompts (create post · add team member · cross-doc search-and-replace · drop a section onto a page) all work end-to-end. Verified by appending a real liveStatsBlock to the /about page via MCP and watching it render.
What broke (and what I learned)
Full notes in POSTMORTEM.md. The TL;DR:
TODO comments rot silently
Day 5 shipped "visually-rough placeholder" section renderers with a comment promising Day 6 would replace them. Day 6 ended up doing draft mode work; the styling swap was deferred and shipped to prod looking like two different sites. Lesson: a sprint-spanning TODO needs an issue or an acceptance criterion on the original commit, not a comment.
GROQ projection changes need consumer audits
Day 5 added asset-> dereference to the image GROQ projection. The shape changed from { asset: { _ref } } to { asset: { _id, url } }. The team page still checked the old _ref, returning null forever. Hand-written types didn't catch it. Lesson: codegen types from GROQ — groqd or @sanity/codegen — so the compiler enforces the contract.
Schema must be deployed for MCP to ground
Setting up MCP felt complete after OAuth, but list_workspace_schemas returned "No schemas deployed." Claude was unmoored on every prompt referencing field names. The local sanity/schemaTypes/ files don't matter to MCP — only the deployed manifest does. Lesson: npx sanity schema deploy is the silent prerequisite — documented in OPERATING.md so the next person doesn't repeat it.
DNS cutovers: list everything before changing anything
Cutover hit two snags. (1) Adding the Vercel A record without removing the Hostinger one would have load-balanced traffic between two completely different sites. (2) An auto-created IPv6 AAAA record on the apex meant IPv6 clients would have followed Hostinger anyway. Vercel correctly refused to validate until both were resolved. Lesson: dump all records on the apex + www first.
Resources
Everything is in the public repo — including this case study's source under app/(marketing)/case-studies/sanity-migration/page.tsx.
- Live site →
- GitHub repo →
- Marketing operating doc (OPERATING.md) →
- Deploy + DNS runbook (DEPLOY.md) →
- Schema reference (SCHEMA.md) →
- Migration writeup (MIGRATION.md) →
- Engineering postmortem →
- PendingLoom — Claude Code + Sanity MCP demo (2:30)