Why Decouple Your CMS from Your Frontend?
Traditional CMS platforms like WordPress tie content management directly to presentation. The server generates HTML on request, which works well for smaller sites. At scale, this model creates constraints: content changes can trigger full rebuilds, you’re locked into one deployment approach, and managing multiple language versions becomes a bottleneck.
A headless CMS solves this by separating content (the head) from how it’s displayed (the body). Your CMS becomes a content API. Your frontend, built with Next.js, consumes that API and handles rendering independently. This unlocks several concrete benefits:
- Content teams can publish without triggering frontend deployments
- You can serve the same content across web, mobile apps, and other channels
- Frontend and content workflows scale independently
- You control rendering performance completely, not the CMS vendor
- Multilingual content becomes a schema design problem, not an infrastructure problem
Choosing Your Headless CMS: Strapi vs Sanity
Two platforms dominate this space for developer teams building at scale.
Strapi
Strapi is open-source and self-hosted. You get full control over your infrastructure and data. The content model is defined through a GraphQL or REST API that Strapi generates from your schema. It’s written in Node.js, so your backend team already knows the stack.
Strengths: Complete control, no vendor lock-in, good for teams with infrastructure expertise, straightforward content modeling.
Trade-offs: You manage hosting, scaling, and backups yourself. The admin UI requires more configuration for advanced workflow features compared to some commercial platforms. Content versioning and approval workflows need additional setup.
Sanity
Sanity is cloud-hosted and proprietary. It uses a structured content model with a powerful query language (GROQ) and a real-time collaboration API. The content editor is highly customizable through code.
Strengths: Managed hosting, real-time collaboration, excellent developer experience, strong performance at scale, workflow and versioning features built in.
Trade-offs: Vendor dependency, pricing scales with content operations, you have less control over infrastructure decisions.
For a multilingual enterprise site, Sanity’s real-time APIs and workflow features often justify the cost. For internal content platforms or sites where you have infrastructure expertise, Strapi gives you more control. Neither choice is wrong; it depends on your team’s priorities and constraints.
Content Schema Design for Multilingual Sites
Your content schema determines how easily you can manage multiple languages. There are two main patterns, each with different trade-offs.
Pattern 1: Localized Documents
Each document contains all language versions. A blog post document has fields for title_en, title_ar, body_en, body_ar, and so on.
{
"_id": "post-001",
"title_en": "Building Scalable APIs",
"title_ar": "بناء واجهات برمجية قابلة للتوسع",
"slug_en": "building-scalable-apis",
"slug_ar": "building-scalable-apis-ar",
"body_en": "...",
"body_ar": "...",
"publishedAt": "2024-01-15",
"author": "ref:author-123"
}
Advantage: Single document to manage, atomic updates, easier to keep translations in sync.
Disadvantage: Schema becomes cluttered as you add languages. Querying requires you to fetch all languages even if you need one.
Pattern 2: Language-Specific Documents with References
Each language has its own document. An English post and an Arabic post are separate documents linked by a shared content ID or reference.
{
"_id": "post-001-en",
"contentId": "post-001",
"language": "en",
"title": "Building Scalable APIs",
"slug": "building-scalable-apis",
"body": "...",
"publishedAt": "2024-01-15"
}
Advantage: Clean schema, you fetch only the language you need, easier to add new languages without schema changes.
Disadvantage: Requires careful coordination to keep translations published together, more complex queries to get all versions of a post.
For most multilingual sites, Pattern 2 scales better. You avoid schema bloat and your API responses stay lean. The trade-off is managing translation workflows carefully. You’ll need tooling to ensure translations are published atomically or in a predictable order.
Building Your Next.js Frontend
Next.js is the natural choice here. Its server-side rendering (SSR) and incremental static regeneration (ISR) give you fine-grained control over performance and freshness.
Static Generation with ISR
For content that changes infrequently, pre-render pages at build time and revalidate on-demand.
// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPostSlugs } from '@/lib/sanity';
export const revalidate = 3600; // Revalidate every hour
export async function generateStaticParams() {
const posts = await getAllPostSlugs();
return posts.map(post => ({
slug: post.slug
}));
}
export async function generateMetadata({ params }) {
const post = await getPostBySlug(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
image: post.image.url
}
};
}
export default async function BlogPost({ params }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.body}</div>
</article>
);
}
This generates static HTML at build time. When content updates, you can trigger a revalidation request, and Next.js regenerates that page in the background. Users see the old page until regeneration completes, then subsequent requests get the new version. For most content sites, this is the sweet spot between performance and freshness.
On-Demand Revalidation
When a content editor publishes in your CMS, trigger a revalidation webhook in Next.js.
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
const REVALIDATE_SECRET = process.env.REVALIDATE_SECRET;
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret');
if (secret !== REVALIDATE_SECRET) {
return NextResponse.json(
{ message: 'Invalid secret' },
{ status: 401 }
);
}
const { slug, type } = await request.json();
if (type === 'blog') {
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog');
}
return NextResponse.json({ revalidated: true });
}
Configure your CMS to POST to this endpoint whenever content publishes. Revalidation happens on the server and users see fresh content on their next request.
Handling Multilingual Routing
Next.js App Router handles i18n routing through a locale segment and middleware. Most teams add [strapi.io](https://strapi.io) or next-intl for locale detection and typed navigation.
// app/[locale]/layout.tsx
export const dynamicParams = true;
export async function generateStaticParams() {
return [
{ locale: 'en' },
{ locale: 'ar' },
{ locale: 'fr' }
];
}
export default function Layout({ children, params }) {
return (
<html lang={params.locale}>
<body>{children}</body>
</html>
);
}
// app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams({ params }) {
const posts = await getAllPostSlugs(params.locale);
return posts.map(post => ({
locale: params.locale,
slug: post.slug
}));
}
This generates URLs like /en/blog/post-slug and /ar/blog/post-slug. Each locale gets its own set of static pages.
Real-Time Publishing Workflows
The gap between publishing in your CMS and users seeing the change should be as small as possible. Here’s a production pattern.
The Flow
Content editor publishes in Sanity or Strapi. The CMS fires a webhook to your Next.js app. Your revalidate endpoint updates the cache. Users see fresh content on their next request. The whole process typically takes a few seconds.
// In your CMS webhook configuration
// URL: https://yourdomain.com/api/revalidate?secret=YOUR_SECRET
// Trigger: On publish
// Payload:
{
"slug": "your-post-slug",
"type": "blog"
}
For frequently changing content like homepage carousels or featured sections, you might use ISR with a shorter revalidation window (e.g., 60 seconds) instead of on-demand revalidation. This balances freshness with cache efficiency.
SEO Optimization for Multilingual Content
Search engines treat each language version as a separate entity. You need to signal relationships clearly.
Hreflang Tags
Tell search engines which page is the version for each language. According to [nayankyada.com](https://nayankyada.com/blog/how-i-build-a-multilingual-website-with-sanity-and-nextjs), every page should list all alternates including itself, links should be reciprocal, and the canonical should point to the page’s own language version.
// lib/seo.ts
export function generateHreflang(slug: string, locales: string[]) {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL;
return locales.map(locale => ({
rel: 'alternate',
hrefLang: locale,
href: `${baseUrl}/${locale}/blog/${slug}`
}));
}
// In your page component
export async function generateMetadata({ params }) {
const hreflang = generateHreflang(params.slug, ['en', 'ar', 'fr']);
return {
alternates: {
languages: Object.fromEntries(
hreflang.map(h => [h.hrefLang, h.href])
)
}
};
}
Language and Region Targeting
If you serve the same language from multiple regions (e.g., English in US and UK), use hreflang with region codes: en-US, en-GB. This helps search engines understand regional content preferences.
Content Metadata in Your Schema
Store SEO metadata per language in your CMS schema. Title, description, and keywords should be language-specific.
{
"_id": "post-001-en",
"language": "en",
"title": "Building Scalable APIs",
"seo": {
"metaTitle": "How to Build Scalable REST APIs | Engineering Guide",
"metaDescription": "Learn REST API design patterns for high-traffic applications. Practical examples with Node.js and PostgreSQL.",
"keywords": ["REST API", "scalability", "backend architecture"]
}
}
Performance and Core Web Vitals
Static generation with ISR naturally helps with Core Web Vitals. Static HTML is fast. But you need to optimize images and JavaScript.
Image Optimization
Use Next.js Image component with automatic format selection and lazy loading.
import Image from 'next/image';
export default function BlogPost({ post }) {
return (
<Image
src={post.image.url}
alt={post.image.alt}
width={1200}
height={630}
priority={false}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
);
}
The Image component handles responsive sizing, modern format delivery (WebP), and lazy loading automatically. This typically improves LCP and CLS significantly.
Code Splitting and Dynamic Imports
For heavy components like comment sections or analytics dashboards, load them only when needed.
import dynamic from 'next/dynamic';
const CommentsSection = dynamic(() => import('@/components/Comments'), {
loading: () => <div>Loading comments...</div>,
ssr: false
});
export default function BlogPost({ post }) {
return (
<article>
<h1>{post.title}</h1>
<div>{post.body}</div>
<CommentsSection postId={post._id} />
</article>
);
}
Monitoring with Web Vitals
Collect real user metrics and identify what’s actually slow in production.
// lib/web-vitals.ts
import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
export function reportWebVitals(metric) {
if (process.env.NODE_ENV === 'production') {
// Send to your analytics service
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(metric),
keepalive: true
});
}
}
getCLS(reportWebVitals);
getFID(reportWebVitals);
getFCP(reportWebVitals);
getLCP(reportWebVitals);
getTTFB(reportWebVitals);
RTL Support for Arabic and Other Languages
If you’re serving Arabic, Hebrew, or Persian, you need proper RTL layout support.
CSS and HTML Direction
Set the dir attribute and use CSS logical properties.
// app/layout.tsx
export default function RootLayout({ children, params }) {
const isRTL = params.locale === 'ar';
return (
<html lang={params.locale} dir={isRTL ? 'rtl' : 'ltr'}>
<body className={isRTL ? 'rtl' : 'ltr'}>
{children}
</body>
</html>
);
}
Use logical CSS properties instead of left/right for layouts that need to flip.
// styles/layout.css
.sidebar {
margin-inline-start: 2rem; /* left in LTR, right in RTL */
padding-inline-end: 1rem; /* right in LTR, left in RTL */
}
Fonts and Typography
Arabic and Persian require specific fonts for proper rendering. Include them in your CSS.
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@400;700&display=swap" rel="stylesheet" />
Then apply them conditionally:
body.rtl {
font-family: 'Cairo', sans-serif;
}
Monitoring and Observability
In production, you need visibility into what’s happening. Set up logging for CMS API calls, revalidation events, and errors.
// lib/logger.ts
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty'
}
});
export default logger;
// In your revalidate endpoint
export async function POST(request: NextRequest) {
try {
const { slug, type } = await request.json();
logger.info({ slug, type }, 'Revalidating content');
revalidatePath(`/${type}/${slug}`);
logger.info({ slug, type }, 'Revalidation complete');
return NextResponse.json({ revalidated: true });
} catch (error) {
logger.error({ error }, 'Revalidation failed');
return NextResponse.json(
{ error: 'Revalidation failed' },
{ status: 500 }
);
}
}
A Production Scenario
You’re building a content platform for a SaaS company serving English, Arabic, and French audiences across multiple regions.
Your CMS schema has language-specific documents with SEO metadata. Your Next.js app uses static generation with ISR for blog posts and dynamic routes for category pages. When a content editor publishes in Sanity, a webhook triggers revalidation. Your monitoring logs every revalidation event. You use hreflang to signal language relationships to search engines. Images are optimized, and your Core Web Vitals stay in the green.
The result: content updates appear quickly, your site ranks well in search, and you can add new languages without touching your codebase. Your content team and engineering team work independently. You’ve decoupled the problem into manageable pieces.
Common Pitfalls to Avoid
- Building separate Next.js apps for each language instead of using i18n routing. This doubles your infrastructure and makes shared components harder to maintain.
- Forgetting to set revalidation secrets. Anyone who finds your revalidate endpoint can trigger builds, which is a DoS vector.
- Storing user-generated content (comments, reviews) in your headless CMS. Use a separate database for that. Your CMS should be authoritative content only.
- Assuming static generation works for personalized content. If your page content depends on the current user, use SSR or dynamic routes instead.
- Skipping content model planning before building your frontend. Spend time on this upfront to avoid restructuring later.
Conclusion
Decoupling your CMS from your frontend isn’t just an architectural choice; it’s a scaling strategy. It lets your content team move fast, your engineering team optimize performance independently, and your product serve global audiences without bottlenecks.
Start with a clear content schema. Choose your CMS based on your infrastructure comfort level. Use Next.js for rendering. Set up webhooks for real-time publishing. Monitor performance. The pieces fit together naturally once you understand the decoupling principle.
You’ll find that this approach scales with your team and your traffic. New languages become a content modeling exercise, not an engineering project. Publishing happens quickly. SEO improves because you control rendering completely. That’s the power of composable architecture.
Should I use Strapi or Sanity for my multilingual site?
Sanity is better if you prioritize managed hosting, real-time collaboration, and workflow features. Strapi is better if you have infrastructure expertise and want complete control over your data and hosting. Neither is wrong; it depends on your team’s priorities and constraints.
How do I handle content translations in a headless CMS?
Two main patterns work: store all languages in one document (simpler but schema bloat), or create separate documents per language linked by a shared ID (cleaner schema, requires coordination). For most multilingual sites, the second pattern scales better.
What’s the difference between ISR and SSR in Next.js?
ISR pre-renders pages at build time and revalidates them on-demand or on a schedule. Users get static HTML, which is fast. SSR renders pages on every request, which is slower but works for personalized content. For content sites, ISR is usually the right choice.
How do I ensure my multilingual site ranks well in search?
Use hreflang tags to tell search engines which page is for which language. Store SEO metadata per language in your CMS. Use Next.js generateMetadata to set titles and descriptions dynamically. Ensure your static generation strategy keeps content fresh. Monitor Core Web Vitals and optimize images.
What happens when a content editor publishes in my CMS?
Your CMS fires a webhook to your Next.js app. The app revalidates the affected pages. The cache updates in the background. On the next user request, they see fresh content. The whole process typically takes a few seconds.