Migrating from Sitecore JSS 22.x to Content SDK: The Complete Guide
After spending several weeks migrating a production Sitecore JSS 22.x application on XM Cloud to the new Sitecore Content SDK with Next.js 15.3.1, I've documented every detail of the process. This guide covers everything from architectural differences to micro-level code changes, multi-tenant theming, multi-language considerations, and the gotchas you'll encounter along the way.
Important Clarification: This guide covers two separate migrations that are often confused:
- JSS SDK → Content SDK (the Sitecore SDK change) - Required migration
- Pages Router → App Router (the Next.js architecture change) - Optional, significant undertaking
Content SDK fully supports Pages Router. You don't need to migrate to App Router to use Content SDK. In fact, staying on Pages Router is the recommended approach for most teams. The App Router migration is a significant architectural change that involves rewriting your entire data fetching layer, component boundaries, and routing structure. This guide covers both paths, but strongly recommends the sequential approach—Content SDK with Pages Router first, then evaluate App Router as a separate future initiative.
Table of Contents
| # | Section | Description |
|---|---|---|
| 1 | Why Migrate Now? | EOL deadline and performance benefits |
| 2 | Understanding the Architectural Shift | JSS vs Content SDK architecture |
| 3 | Choosing Your Migration Path | Pages Router vs App Router options |
| 4 | Step-by-Step Migration Guide | 8 detailed migration steps |
| 5 | Multi-Tenant Architecture | Different themes per tenant |
| 6 | Multi-Language Migration | i18n and locale handling |
| 7 | Breaking Changes | Critical changes to address |
| 8 | Next.js 15 Benefits | Performance improvements |
| 9 | Migration Checklist | Pre/Post migration tasks |
| 10 | Common Pitfalls | Solutions to common issues |
Why Migrate Now?
Before diving into the technical details, let's address the elephant in the room: JSS SDK 22.x reaches end-of-life in June 2026 for XM Cloud and SitecoreAI. This isn't optional—it's a deadline.
But beyond the EOL date, Content SDK brings significant improvements:
| Metric | JSS 22.x | Content SDK | Improvement |
|---|---|---|---|
| Bundle Size | Baseline | 49% smaller | -49% |
| File Count | Baseline | 81% fewer | -81% |
| Lines of Code | Baseline | 39% less | -39% |
| API Response Time | Baseline | 40-80% faster | Up to 80% |
These aren't marketing numbers—they're what I measured in our production environment.
Understanding the Architectural Shift
The fundamental change from JSS to Content SDK is the shift from a plugin-based architecture to a unified client approach.
JSS 22.x Architecture
JSS used scattered plugins for different concerns:
src/
├── lib/
│ ├── layout-service.ts # Layout fetching
│ ├── dictionary-service.ts # Translations
│ ├── sitemap-service.ts # Sitemap generation
│ ├── personalize-plugin.ts # Personalization
│ └── redirect-plugin.ts # Redirect handling
├── middleware.ts # Plugin-based middleware
└── pages/
└── [[...path]].tsx # getStaticProps data fetchingContent SDK Architecture
Content SDK consolidates everything into SitecoreClient. The directory structure depends on whether you use Pages Router or App Router:
Content SDK with Pages Router:
src/
├── sitecore.config.ts # Centralized configuration
├── sitecore.cli.config.ts # CLI configuration
├── lib/
│ └── components/
│ └── component-map.ts # Explicit component registration
├── middleware.ts # defineMiddleware pattern
└── pages/
└── [[...path]].tsx # getStaticProps with SitecoreClientContent SDK with App Router:
src/
├── sitecore.config.ts # Centralized configuration
├── sitecore.cli.config.ts # CLI configuration
├── lib/
│ └── components/
│ └── component-map.ts # Explicit component registration
├── middleware.ts # defineMiddleware pattern
└── app/
└── [[...path]]/
└── page.tsx # Direct SitecoreClient usage (Server Component)Choosing Your Migration Path
Before diving into the migration steps, you need to decide which path suits your project:
Option 1: Content SDK Only (Stay on Pages Router)
Best for:
- Teams wanting minimal disruption
- Projects with tight deadlines
- Large codebases where App Router migration is a separate initiative
- Teams unfamiliar with React Server Components
What changes:
- SDK packages and imports
- Service classes → SitecoreClient API
- Middleware pattern
- Component field types
What stays the same:
pages/directory structuregetStaticProps/getServerSidePropspatterns- Client-side data fetching patterns
- Existing component architecture
Option 2: Content SDK + App Router (Significant Undertaking)
⚠️ Warning: Migrating to App Router is a significant architectural change that goes far beyond switching directories. It requires fundamentally rethinking how your application fetches data, manages state, and handles component boundaries. Do not underestimate this effort.
Best for:
- Greenfield projects or complete rewrites with dedicated timeline
- Teams with extensive React Server Components experience
- Projects where the existing codebase is small or needs significant refactoring anyway
- Organizations with dedicated time for learning and stabilization
What changes (significant rewrites required):
- Everything from Option 1, plus:
- Complete directory restructure:
pages/→app/(every route file changes) - Data fetching rewrite:
getStaticProps/getServerSideProps→ async Server Components - Component architecture overhaul: Must define clear Client/Server component boundaries with
'use client'directives - State management rethink: Many client-side state patterns don't work with Server Components
- Third-party library audit: Not all libraries support React Server Components yet
- Testing strategy update: Server Components require different testing approaches
Estimated additional effort: 2-4x the effort of Content SDK migration alone, depending on codebase size
Recommended Approach: Content SDK with Pages Router
Based on my experience, I strongly recommend staying on Pages Router for your Content SDK migration:
Phase 1: JSS → Content SDK (Pages Router) ← RECOMMENDED STOPPING POINT
↓ Validate & stabilize (2-4 weeks)
↓ Production deployment
↓ Monitor for stability
Phase 2: Pages Router → App Router (OPTIONAL - separate initiative)
↓ Only if business requirements demand it
↓ Dedicated project with separate timeline
↓ Expect 2-4x additional effortWhy stay on Pages Router?
- Proven stability – Pages Router is mature and battle-tested
- Lower risk – No need to learn new paradigms during a critical migration
- Faster time-to-value – Get Content SDK benefits without additional complexity
- Team productivity – Your team already knows Pages Router patterns
- Third-party compatibility – All existing libraries work without changes
When to consider App Router (future initiative):
- Your team has completed Content SDK migration and is stable in production
- You have dedicated time (weeks/months) for the architectural rewrite
- You need specific App Router features (streaming, partial prerendering)
- Your team has trained on React Server Components
The rest of this guide shows both approaches for completeness, but the Pages Router examples should be your primary focus. App Router examples are provided for reference if you decide to pursue that migration later as a separate project.
Step-by-Step Migration Guide
Step 1: Update Dependencies
First, remove all JSS packages and install Content SDK:
# Remove JSS packages
npm uninstall @sitecore-jss/sitecore-jss-nextjs \
@sitecore-jss/sitecore-jss-cli \
@sitecore-jss/sitecore-jss-dev-tools \
@sitecore-jss/sitecore-jss-react \
--legacy-peer-deps
# Install Content SDK
npm install @sitecore-content-sdk/nextjs@latest \
@sitecore-content-sdk/core@latest \
--legacy-peer-depsVerify your package.json no longer contains any @sitecore-jss/* packages:
{
"dependencies": {
"@sitecore-content-sdk/core": "^1.3.2",
"@sitecore-content-sdk/nextjs": "^1.3.2",
"next": "15.3.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
}Note: Content SDK 1.x requires Next.js 15.3.1 or higher. It does not yet support Next.js 16. Ensure you're using the exact version
15.3.1for the most stable experience.
Step 2: Create Sitecore Configuration Files
Create the centralized configuration file at the project root:
// sitecore.config.ts
import { SitecoreConfig } from '@sitecore-content-sdk/nextjs';
import componentMap from './src/lib/components/component-map';
export function createSitecoreConfig(): SitecoreConfig {
return {
// API Configuration
apiUrl: process.env.SITECORE_API_URL!,
apiKey: process.env.SITECORE_API_KEY!,
// Site Configuration
siteName: process.env.SITECORE_SITE_NAME!,
defaultLanguage: 'en',
// Editing Configuration
editingSecret: process.env.SITECORE_EDITING_SECRET!,
// Component Registration
componentMap,
// Optional: Multi-site configuration
sites: [
{
name: 'primary-site',
hostName: 'www.example.com',
language: 'en'
},
{
name: 'primary-site',
hostName: 'de.example.com',
language: 'de'
}
],
// Optional: Caching configuration
cache: {
enabled: true,
ttl: 300 // 5 minutes
}
};
}
export default createSitecoreConfig;Create the CLI configuration:
// sitecore.cli.config.ts
export default {
sitecore: {
instancePath: process.env.SITECORE_INSTANCE_PATH!,
apiKey: process.env.SITECORE_API_KEY!,
deploySecret: process.env.SITECORE_DEPLOY_SECRET!,
},
templates: {
component: './src/templates/component.tsx.template',
page: './src/templates/page.tsx.template'
}
};Step 3: Update Environment Variables
Rename your environment variables to match Content SDK conventions:
# .env.local - Before (JSS 22.x)
SITECORE_API_HOST=https://your-instance.sitecorecloud.io
JSS_EDITING_SECRET=your-editing-secret-here
PUBLIC_URL=https://www.example.com
ASSET_PREFIX=/sitecore
# .env.local - After (Content SDK)
SITECORE_API_URL=https://your-instance.sitecorecloud.io
SITECORE_API_KEY=your-api-key-here
SITECORE_SITE_NAME=your-site-name
SITECORE_EDITING_SECRET=your-editing-secret-here
# Removed: PUBLIC_URL, ASSET_PREFIX (no longer needed)Here's the complete mapping of environment variable changes:
| JSS 22.x Variable | Content SDK Variable | Notes |
|---|---|---|
SITECORE_API_HOST | SITECORE_API_URL | Same purpose, renamed |
JSS_EDITING_SECRET | SITECORE_EDITING_SECRET | Same purpose, renamed |
PUBLIC_URL | (removed) | No longer needed |
assetPrefix | (removed) | No longer needed |
Step 4: Create Component Map
Replace the dynamic component factory with explicit component registration:
// src/lib/components/component-map.ts
import { ComponentMap } from '@sitecore-content-sdk/nextjs';
// Import all your components
import Hero from '@/components/Hero';
import ContentBlock from '@/components/ContentBlock';
import Navigation from '@/components/Navigation';
import Footer from '@/components/Footer';
import ImageGallery from '@/components/ImageGallery';
import CallToAction from '@/components/CallToAction';
import Accordion from '@/components/Accordion';
import CardList from '@/components/CardList';
import VideoPlayer from '@/components/VideoPlayer';
import FormContainer from '@/components/FormContainer';
import RichText from '@/components/RichText';
import Promo from '@/components/Promo';
import ColumnSplitter from '@/components/ColumnSplitter';
// Explicitly map component names to components
// This enables tree-shaking and smaller bundles
export const componentMap: ComponentMap = {
// Layout Components
Hero: Hero,
Navigation: Navigation,
Footer: Footer,
ColumnSplitter: ColumnSplitter,
// Content Components
ContentBlock: ContentBlock,
RichText: RichText,
ImageGallery: ImageGallery,
VideoPlayer: VideoPlayer,
// Interactive Components
Accordion: Accordion,
CardList: CardList,
CallToAction: CallToAction,
FormContainer: FormContainer,
Promo: Promo,
};
export default componentMap;You can auto-generate this file using the CLI:
npx sitecore-tools project component generate-map --watchStep 5: Migrate Data Fetching
This is the most significant code change. For most teams, Option A (Pages Router) is the recommended approach.
Recommendation: Stay on Pages Router. You'll keep your familiar
getStaticProps/getServerSidePropspatterns and simply replace the JSS service calls withSitecoreClient. This is a straightforward find-and-replace operation that minimizes risk.Option B (App Router) is only shown for reference if you decide to pursue that migration as a separate future initiative. It requires significantly more changes and should not be attempted during your initial Content SDK migration.
Before (JSS 22.x - pages/[[...path]].tsx):
// pages/[[...path]].tsx - JSS 22.x
import { GetStaticPaths, GetStaticProps } from 'next';
import {
SitecorePageProps,
sitecorePagePropsFactory,
layoutServiceFactory,
dictionaryServiceFactory
} from '@sitecore-jss/sitecore-jss-nextjs';
import Layout from '@/components/Layout';
const layoutService = layoutServiceFactory.create();
const dictionaryService = dictionaryServiceFactory.create();
export default function Page({ layoutData, dictionary }: SitecorePageProps) {
return <Layout layoutData={layoutData} dictionary={dictionary} />;
}
export const getStaticPaths: GetStaticPaths = async () => {
// Fetch paths from sitemap service
const paths = await sitemapService.fetchSitemap();
return {
paths: paths.map((path) => ({
params: { path: path.split('/').filter(Boolean) }
})),
fallback: 'blocking'
};
};
export const getStaticProps: GetStaticProps<SitecorePageProps> = async (context) => {
const path = Array.isArray(context.params?.path)
? context.params.path.join('/')
: context.params?.path || '/';
const locale = context.locale || 'en';
try {
const [layoutData, dictionary] = await Promise.all([
layoutService.fetchLayoutData(path, locale),
dictionaryService.fetchDictionaryData(locale)
]);
return {
props: {
layoutData,
dictionary,
locale
},
revalidate: 60
};
} catch (error) {
return { notFound: true };
}
};✅ Option A: After (Content SDK - Pages Router) — RECOMMENDED
This is the recommended approach. Keep your pages/ directory structure and simply update the service calls:
// pages/[[...path]].tsx - Content SDK with Pages Router
import { GetStaticPaths, GetStaticProps } from 'next';
import { SitecoreClient } from '@sitecore-content-sdk/nextjs/sitecore-client';
import { SitecorePage } from '@sitecore-content-sdk/nextjs';
import type { LayoutServiceData } from '@sitecore-content-sdk/nextjs';
import createSitecoreConfig from '@/sitecore.config';
const config = createSitecoreConfig();
interface PageProps {
layoutData: LayoutServiceData;
dictionary: Record<string, string>;
locale: string;
}
export default function Page({ layoutData, dictionary }: PageProps) {
return (
<SitecorePage
layoutData={layoutData}
dictionary={dictionary}
componentMap={config.componentMap}
/>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
// Use SitecoreClient instead of sitemap service
const { sitemap } = await SitecoreClient.sitemap({
siteName: config.siteName,
language: config.defaultLanguage
});
return {
paths: sitemap.map((item) => ({
params: { path: item.path.split('/').filter(Boolean) }
})),
fallback: 'blocking'
};
};
export const getStaticProps: GetStaticProps<PageProps> = async (context) => {
const path = Array.isArray(context.params?.path)
? `/${context.params.path.join('/')}`
: '/';
const locale = context.locale || config.defaultLanguage;
try {
// Single SitecoreClient call replaces multiple service calls
const { layoutData, dictionary, error } = await SitecoreClient.layout({
path,
language: locale,
siteName: config.siteName,
includeDictionary: true
});
if (error || !layoutData) {
return { notFound: true };
}
return {
props: {
layoutData,
dictionary: dictionary || {},
locale
},
revalidate: 60
};
} catch (error) {
console.error('Failed to fetch page data:', error);
return { notFound: true };
}
};Key changes for Pages Router:
- Replace
layoutServiceFactorywithSitecoreClient.layout() - Replace
dictionaryServiceFactorywithincludeDictionary: trueoption - Replace
sitemapServicewithSitecoreClient.sitemap() - Update type imports from
@sitecore-content-sdk/nextjs - Keep
getStaticProps/getStaticPathspatterns unchanged
⚠️ Option B: After (Content SDK - App Router) — OPTIONAL, SIGNIFICANT EFFORT
Note: This section is provided for future reference only. Do not attempt this during your initial Content SDK migration. The App Router migration is a separate, significant undertaking that should be planned as its own project after you've stabilized on Content SDK with Pages Router.
// app/[[...path]]/page.tsx - Content SDK (FUTURE REFERENCE)
import { SitecoreClient } from '@sitecore-content-sdk/nextjs/sitecore-client';
import { SitecorePage } from '@sitecore-content-sdk/nextjs';
import { notFound } from 'next/navigation';
import createSitecoreConfig from '@/sitecore.config';
// Initialize client with config
const config = createSitecoreConfig();
// Generate static params for SSG
export async function generateStaticParams() {
const { sitemap } = await SitecoreClient.sitemap({
siteName: config.siteName,
language: config.defaultLanguage
});
return sitemap.map((item) => ({
path: item.path.split('/').filter(Boolean)
}));
}
// Page component (Server Component by default in App Router)
export default async function Page({
params
}: {
params: { path?: string[] }
}) {
const path = params.path?.join('/') || '/';
try {
// Single unified API call replaces multiple service calls
const { layoutData, dictionary, error } = await SitecoreClient.layout({
path: `/${path}`,
language: 'en',
siteName: config.siteName,
includeDictionary: true
});
if (error || !layoutData) {
notFound();
}
return (
<SitecorePage
layoutData={layoutData}
dictionary={dictionary}
componentMap={config.componentMap}
/>
);
} catch (error) {
console.error('Failed to fetch page data:', error);
notFound();
}
}
// Metadata generation
export async function generateMetadata({
params
}: {
params: { path?: string[] }
}) {
const path = params.path?.join('/') || '/';
const { layoutData } = await SitecoreClient.layout({
path: `/${path}`,
language: 'en',
siteName: config.siteName
});
const fields = layoutData?.sitecore?.route?.fields;
return {
title: fields?.Title?.value || 'Default Title',
description: fields?.MetaDescription?.value || 'Default description',
openGraph: {
title: fields?.OgTitle?.value || fields?.Title?.value,
description: fields?.OgDescription?.value,
images: fields?.OgImage?.value?.src ? [fields.OgImage.value.src] : []
}
};
}Step 6: Migrate Middleware
Replace the plugin-based middleware with defineMiddleware.
Note: Middleware works the same way for both Pages Router and App Router—this step applies to both migration paths.
Before (JSS 22.x - middleware.ts):
// middleware.ts - JSS 22.x
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { withSitecoreMiddleware } from '@sitecore-jss/sitecore-jss-nextjs/middleware';
import { redirectsPlugin } from './lib/middleware/plugins/redirects';
import { personalizePlugin } from './lib/middleware/plugins/personalize';
import { analyticsPlugin } from './lib/middleware/plugins/analytics';
// Plugin chain
const plugins = [
redirectsPlugin,
personalizePlugin,
analyticsPlugin
];
export default withSitecoreMiddleware(plugins);
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
]
};After (Content SDK - middleware.ts):
// middleware.ts - Content SDK
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { defineMiddleware } from '@sitecore-content-sdk/nextjs/middleware';
import { SitecoreClient } from '@sitecore-content-sdk/nextjs/sitecore-client';
export default defineMiddleware(async (request: NextRequest) => {
const response = NextResponse.next();
const path = request.nextUrl.pathname;
// Handle redirects via SitecoreClient
try {
const { redirect } = await SitecoreClient.redirects({
path,
siteName: process.env.SITECORE_SITE_NAME!
});
if (redirect) {
return NextResponse.redirect(
new URL(redirect.target, request.url),
redirect.statusCode
);
}
} catch (error) {
console.error('Redirect check failed:', error);
}
// Handle locale detection
const locale = request.cookies.get('NEXT_LOCALE')?.value || 'en';
response.cookies.set('NEXT_LOCALE', locale);
// Add custom headers for analytics
response.headers.set('x-sitecore-path', path);
response.headers.set('x-sitecore-locale', locale);
return response;
});
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sitecore).*)',
]
};Step 7: Migrate Components
Update individual components to use Content SDK field types:
Before (JSS 22.x Component):
// components/Hero.tsx - JSS 22.x
import {
Text,
Image,
RichText,
Link,
withDatasourceCheck
} from '@sitecore-jss/sitecore-jss-nextjs';
import { ComponentProps } from '@sitecore-jss/sitecore-jss-nextjs';
interface HeroFields {
Heading: { value: string };
Subheading: { value: string };
BackgroundImage: { value: { src: string; alt: string } };
CtaLink: { value: { href: string; text: string } };
}
type HeroProps = ComponentProps & {
fields: HeroFields;
};
const Hero = ({ fields }: HeroProps): JSX.Element => {
return (
<section className="hero">
<Image field={fields.BackgroundImage} className="hero-bg" />
<div className="hero-content">
<Text field={fields.Heading} tag="h1" />
<RichText field={fields.Subheading} />
<Link field={fields.CtaLink} className="cta-button" />
</div>
</section>
);
};
export default withDatasourceCheck()(Hero);After (Content SDK Component):
// components/Hero.tsx - Content SDK
import {
Text,
Image,
RichText,
Link
} from '@sitecore-content-sdk/nextjs/components';
import type { ComponentRendering, Field, ImageField, LinkField } from '@sitecore-content-sdk/nextjs';
interface HeroFields {
Heading: Field<string>;
Subheading: Field<string>;
BackgroundImage: ImageField;
CtaLink: LinkField;
}
interface HeroProps {
rendering: ComponentRendering;
fields: HeroFields;
}
export default function Hero({ fields, rendering }: HeroProps) {
// Content SDK provides direct field access
// No HOC wrapper needed
if (!fields) {
return (
<section className="hero hero--empty">
<p>Hero component has no data source</p>
</section>
);
}
return (
<section className="hero" data-component-id={rendering.uid}>
<Image
field={fields.BackgroundImage}
className="hero-bg"
// Content SDK supports Next.js Image optimization
fill
priority
/>
<div className="hero-content">
<Text field={fields.Heading} tag="h1" className="hero-title" />
<RichText field={fields.Subheading} className="hero-subtitle" />
<Link field={fields.CtaLink} className="cta-button">
{fields.CtaLink?.value?.text || 'Learn More'}
</Link>
</div>
</section>
);
}Step 8: Update Placeholder Components
If you have custom placeholder implementations, migrate them:
Before (JSS 22.x):
// components/Layout.tsx - JSS 22.x
import { Placeholder } from '@sitecore-jss/sitecore-jss-nextjs';
export default function Layout({ layoutData }) {
return (
<div className="layout">
<header>
<Placeholder name="header" rendering={layoutData} />
</header>
<main>
<Placeholder name="main" rendering={layoutData} />
</main>
<footer>
<Placeholder name="footer" rendering={layoutData} />
</footer>
</div>
);
}After (Content SDK):
// components/Layout.tsx - Content SDK
import { Placeholder } from '@sitecore-content-sdk/nextjs/components';
import type { LayoutServiceData } from '@sitecore-content-sdk/nextjs';
import componentMap from '@/lib/components/component-map';
interface LayoutProps {
layoutData: LayoutServiceData;
}
export default function Layout({ layoutData }: LayoutProps) {
const route = layoutData?.sitecore?.route;
if (!route) {
return <div>No route data available</div>;
}
return (
<div className="layout">
<header>
<Placeholder
name="header"
rendering={route}
componentMap={componentMap}
/>
</header>
<main>
<Placeholder
name="main"
rendering={route}
componentMap={componentMap}
/>
</main>
<footer>
<Placeholder
name="footer"
rendering={route}
componentMap={componentMap}
/>
</footer>
</div>
);
}Multi-Tenant Architecture with Different Themes
If your application serves multiple tenants (brands/sites) with different themes from the same codebase, Content SDK requires a different approach than JSS.
Multi-Tenant Directory Structure
src/
├── app/
│ └── [[...path]]/
│ └── page.tsx
├── themes/
│ ├── brand-a/
│ │ ├── globals.css
│ │ ├── theme.config.ts
│ │ └── components/
│ │ └── overrides/
│ │ └── Hero.tsx # Brand-specific Hero variant
│ ├── brand-b/
│ │ ├── globals.css
│ │ ├── theme.config.ts
│ │ └── components/
│ │ └── overrides/
│ │ └── Hero.tsx
│ └── shared/
│ └── base.css
├── lib/
│ ├── components/
│ │ ├── component-map.ts # Base component map
│ │ └── component-map-factory.ts # Dynamic map per tenant
│ └── theme/
│ ├── theme-provider.tsx
│ └── theme-resolver.ts
└── sitecore.config.tsTheme Configuration Per Tenant
// themes/brand-a/theme.config.ts
export const brandATheme = {
name: 'brand-a',
siteName: 'brand-a-site',
colors: {
primary: '#0066CC',
secondary: '#FF6600',
accent: '#00CC66'
},
fonts: {
heading: 'Inter, sans-serif',
body: 'Open Sans, sans-serif'
},
components: {
// Override specific components for this brand
Hero: () => import('./components/overrides/Hero'),
Navigation: () => import('./components/overrides/Navigation')
}
};
// themes/brand-b/theme.config.ts
export const brandBTheme = {
name: 'brand-b',
siteName: 'brand-b-site',
colors: {
primary: '#8B0000',
secondary: '#FFD700',
accent: '#4B0082'
},
fonts: {
heading: 'Playfair Display, serif',
body: 'Lato, sans-serif'
},
components: {
// Brand B uses different overrides
Hero: () => import('./components/overrides/Hero'),
Footer: () => import('./components/overrides/Footer')
}
};Dynamic Component Map Factory
// lib/components/component-map-factory.ts
import { ComponentMap } from '@sitecore-content-sdk/nextjs';
import { baseComponentMap } from './component-map';
// Theme configs
import { brandATheme } from '@/themes/brand-a/theme.config';
import { brandBTheme } from '@/themes/brand-b/theme.config';
const themeConfigs = {
'brand-a': brandATheme,
'brand-b': brandBTheme
};
export async function createComponentMapForTenant(
tenantId: string
): Promise<ComponentMap> {
const theme = themeConfigs[tenantId];
if (!theme) {
// Fall back to base components
return baseComponentMap;
}
// Start with base components
const mergedMap = { ...baseComponentMap };
// Override with tenant-specific components
for (const [componentName, importFn] of Object.entries(theme.components || {})) {
try {
const module = await importFn();
mergedMap[componentName] = module.default || module;
} catch (error) {
console.warn(`Failed to load ${componentName} for ${tenantId}:`, error);
// Keep base component on failure
}
}
return mergedMap;
}Theme Provider with CSS Variables
// lib/theme/theme-provider.tsx
'use client';
import { createContext, useContext, ReactNode } from 'react';
interface ThemeConfig {
name: string;
colors: Record<string, string>;
fonts: Record<string, string>;
}
const ThemeContext = createContext<ThemeConfig | null>(null);
export function ThemeProvider({
theme,
children
}: {
theme: ThemeConfig;
children: ReactNode;
}) {
// Generate CSS custom properties from theme
const cssVariables = {
'--color-primary': theme.colors.primary,
'--color-secondary': theme.colors.secondary,
'--color-accent': theme.colors.accent,
'--font-heading': theme.fonts.heading,
'--font-body': theme.fonts.body,
} as React.CSSProperties;
return (
<ThemeContext.Provider value={theme}>
<div style={cssVariables} data-theme={theme.name}>
{children}
</div>
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}Tenant Resolution in Middleware
// middleware.ts - Multi-tenant Content SDK
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { defineMiddleware } from '@sitecore-content-sdk/nextjs/middleware';
const TENANT_DOMAIN_MAP: Record<string, string> = {
'www.brand-a.com': 'brand-a',
'www.brand-b.com': 'brand-b',
'brand-a.localhost': 'brand-a',
'brand-b.localhost': 'brand-b'
};
export default defineMiddleware(async (request: NextRequest) => {
const response = NextResponse.next();
const hostname = request.headers.get('host') || '';
// Resolve tenant from hostname
const tenantId = TENANT_DOMAIN_MAP[hostname] || 'brand-a';
// Pass tenant to app via headers
response.headers.set('x-tenant-id', tenantId);
// Set Sitecore site name based on tenant
const siteName = `${tenantId}-site`;
response.headers.set('x-sitecore-site', siteName);
return response;
});Multi-Tenant Page Component
// app/[[...path]]/page.tsx - Multi-tenant
import { headers } from 'next/headers';
import { SitecoreClient } from '@sitecore-content-sdk/nextjs/sitecore-client';
import { SitecorePage } from '@sitecore-content-sdk/nextjs';
import { notFound } from 'next/navigation';
import { createComponentMapForTenant } from '@/lib/components/component-map-factory';
import { ThemeProvider } from '@/lib/theme/theme-provider';
// Import theme configs
import { brandATheme } from '@/themes/brand-a/theme.config';
import { brandBTheme } from '@/themes/brand-b/theme.config';
const themes = {
'brand-a': brandATheme,
'brand-b': brandBTheme
};
export default async function Page({
params
}: {
params: { path?: string[] }
}) {
const headersList = await headers();
const tenantId = headersList.get('x-tenant-id') || 'brand-a';
const siteName = headersList.get('x-sitecore-site') || 'brand-a-site';
const path = params.path?.join('/') || '/';
// Get tenant-specific component map
const componentMap = await createComponentMapForTenant(tenantId);
const theme = themes[tenantId] || themes['brand-a'];
try {
const { layoutData, dictionary } = await SitecoreClient.layout({
path: `/${path}`,
language: 'en',
siteName
});
if (!layoutData) {
notFound();
}
return (
<ThemeProvider theme={theme}>
<SitecorePage
layoutData={layoutData}
dictionary={dictionary}
componentMap={componentMap}
/>
</ThemeProvider>
);
} catch (error) {
console.error('Failed to fetch page data:', error);
notFound();
}
}Theme-Aware CSS with Tailwind
/* themes/shared/base.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Default theme - overridden per tenant */
--color-primary: #0066CC;
--color-secondary: #FF6600;
--color-accent: #00CC66;
--font-heading: 'Inter', sans-serif;
--font-body: 'Open Sans', sans-serif;
}
}
@layer components {
.btn-primary {
@apply px-6 py-3 rounded-lg font-semibold transition-colors;
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
filter: brightness(1.1);
}
.heading {
font-family: var(--font-heading);
}
.body-text {
font-family: var(--font-body);
}
}
/* Brand-specific overrides */
[data-theme="brand-a"] {
.hero {
@apply bg-gradient-to-r from-blue-600 to-blue-800;
}
}
[data-theme="brand-b"] {
.hero {
@apply bg-gradient-to-r from-red-800 to-amber-600;
}
}Multi-Language Migration Considerations
Multi-language support in Content SDK differs significantly from JSS. Here's what changes:
JSS 22.x Multi-Language Structure
# JSS 22.x used Next.js pages with locale routing
pages/
├── [[...path]].tsx
└── ...
# next.config.js
i18n: {
locales: ['en', 'de', 'fr', 'es'],
defaultLocale: 'en',
localeDetection: true
}Content SDK Multi-Language Structure
# Content SDK with App Router uses route groups or dynamic segments
app/
├── [locale]/
│ └── [[...path]]/
│ └── page.tsx
├── layout.tsx
└── ...Multi-Language Configuration
// sitecore.config.ts - Multi-language
import { SitecoreConfig } from '@sitecore-content-sdk/nextjs';
export function createSitecoreConfig(): SitecoreConfig {
return {
apiUrl: process.env.SITECORE_API_URL!,
apiKey: process.env.SITECORE_API_KEY!,
siteName: process.env.SITECORE_SITE_NAME!,
// Multi-language configuration
defaultLanguage: 'en',
supportedLanguages: ['en', 'de', 'fr', 'es', 'ja', 'zh'],
// Language fallback chain
languageFallback: {
'de-AT': ['de', 'en'],
'de-CH': ['de', 'en'],
'fr-CA': ['fr', 'en'],
'zh-TW': ['zh', 'en']
},
// Per-language site mapping (for multi-region)
sites: [
{ name: 'global-site', hostName: 'www.example.com', language: 'en' },
{ name: 'global-site', hostName: 'de.example.com', language: 'de' },
{ name: 'global-site', hostName: 'fr.example.com', language: 'fr' },
{ name: 'global-site', hostName: 'es.example.com', language: 'es' }
]
};
}Multi-Language Page Component
// app/[locale]/[[...path]]/page.tsx
import { SitecoreClient } from '@sitecore-content-sdk/nextjs/sitecore-client';
import { SitecorePage } from '@sitecore-content-sdk/nextjs';
import { notFound } from 'next/navigation';
import createSitecoreConfig from '@/sitecore.config';
const config = createSitecoreConfig();
// Generate static params for all locales
export async function generateStaticParams() {
const params: { locale: string; path: string[] }[] = [];
for (const language of config.supportedLanguages) {
const { sitemap } = await SitecoreClient.sitemap({
siteName: config.siteName,
language
});
for (const item of sitemap) {
params.push({
locale: language,
path: item.path.split('/').filter(Boolean)
});
}
}
return params;
}
export default async function Page({
params
}: {
params: { locale: string; path?: string[] }
}) {
const { locale, path: pathSegments } = params;
const path = pathSegments?.join('/') || '/';
// Validate locale
if (!config.supportedLanguages.includes(locale)) {
notFound();
}
try {
const { layoutData, dictionary } = await SitecoreClient.layout({
path: `/${path}`,
language: locale,
siteName: config.siteName,
includeDictionary: true
});
if (!layoutData) {
notFound();
}
return (
<SitecorePage
layoutData={layoutData}
dictionary={dictionary}
componentMap={config.componentMap}
/>
);
} catch (error) {
console.error(`Failed to fetch page data for ${locale}:`, error);
notFound();
}
}
// Generate metadata with language
export async function generateMetadata({
params
}: {
params: { locale: string; path?: string[] }
}) {
const { locale, path: pathSegments } = params;
const path = pathSegments?.join('/') || '/';
const { layoutData } = await SitecoreClient.layout({
path: `/${path}`,
language: locale,
siteName: config.siteName
});
const fields = layoutData?.sitecore?.route?.fields;
return {
title: fields?.Title?.value || 'Default Title',
description: fields?.MetaDescription?.value,
alternates: {
languages: Object.fromEntries(
config.supportedLanguages.map(lang => [
lang,
`/${lang}${path === '/' ? '' : `/${path}`}`
])
)
}
};
}Language Switcher Component
// components/LanguageSwitcher.tsx
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
interface LanguageSwitcherProps {
currentLocale: string;
supportedLanguages: string[];
languageNames: Record<string, string>;
}
export function LanguageSwitcher({
currentLocale,
supportedLanguages,
languageNames
}: LanguageSwitcherProps) {
const pathname = usePathname();
// Remove current locale from path to get base path
const pathWithoutLocale = pathname.replace(`/${currentLocale}`, '') || '/';
return (
<nav aria-label="Language selection" className="language-switcher">
<ul className="flex gap-2">
{supportedLanguages.map((lang) => {
const isActive = lang === currentLocale;
const href = `/${lang}${pathWithoutLocale}`;
return (
<li key={lang}>
<Link
href={href}
className={`px-3 py-1 rounded ${
isActive
? 'bg-primary text-white'
: 'bg-gray-100 hover:bg-gray-200'
}`}
aria-current={isActive ? 'page' : undefined}
hrefLang={lang}
>
{languageNames[lang] || lang.toUpperCase()}
</Link>
</li>
);
})}
</ul>
</nav>
);
}Dictionary Service Migration
Before (JSS 22.x):
// lib/dictionary-service.ts - JSS 22.x
import { DictionaryService } from '@sitecore-jss/sitecore-jss-nextjs';
const dictionaryService = new DictionaryService({
apiHost: process.env.SITECORE_API_HOST!,
apiKey: process.env.SITECORE_API_KEY!,
siteName: process.env.SITECORE_SITE_NAME!
});
export async function getDictionary(language: string) {
return dictionaryService.fetchDictionaryData(language);
}After (Content SDK):
// lib/dictionary.ts - Content SDK
import { SitecoreClient } from '@sitecore-content-sdk/nextjs/sitecore-client';
export async function getDictionary(language: string) {
const { dictionary } = await SitecoreClient.dictionary({
siteName: process.env.SITECORE_SITE_NAME!,
language
});
return dictionary;
}
// Usage in component
import { getDictionary } from '@/lib/dictionary';
export default async function MyComponent({ locale }: { locale: string }) {
const dictionary = await getDictionary(locale);
return (
<div>
<h1>{dictionary['common.welcome'] || 'Welcome'}</h1>
<p>{dictionary['common.description'] || 'Description'}</p>
</div>
);
}Multi-Language Middleware
// middleware.ts - Multi-language Content SDK
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { defineMiddleware } from '@sitecore-content-sdk/nextjs/middleware';
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es', 'ja', 'zh'];
const DEFAULT_LOCALE = 'en';
// Locale detection priorities
function detectLocale(request: NextRequest): string {
// 1. Check URL path
const pathname = request.nextUrl.pathname;
const pathLocale = pathname.split('/')[1];
if (SUPPORTED_LOCALES.includes(pathLocale)) {
return pathLocale;
}
// 2. Check cookie
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
return cookieLocale;
}
// 3. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().substring(0, 2))
.find(lang => SUPPORTED_LOCALES.includes(lang));
if (preferred) {
return preferred;
}
}
return DEFAULT_LOCALE;
}
export default defineMiddleware(async (request: NextRequest) => {
const pathname = request.nextUrl.pathname;
// Skip locale detection for static files and API routes
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api') ||
pathname.includes('.')
) {
return NextResponse.next();
}
// Check if pathname has locale
const pathnameHasLocale = SUPPORTED_LOCALES.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (!pathnameHasLocale) {
// Detect and redirect to localized URL
const locale = detectLocale(request);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
const response = NextResponse.redirect(newUrl);
response.cookies.set('NEXT_LOCALE', locale, {
maxAge: 60 * 60 * 24 * 365 // 1 year
});
return response;
}
return NextResponse.next();
});
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
};Breaking Changes You Must Address
1. Experience Editor Removed
Content SDK does not support Experience Editor. All visual editing now happens through XM Cloud Pages Builder.
Impact: If you have custom Experience Editor integrations or chromes, they must be removed.
Before (JSS 22.x):
// Conditional rendering for Experience Editor
import { isEditorActive } from '@sitecore-jss/sitecore-jss-nextjs';
if (isEditorActive()) {
// Experience Editor specific code
}After (Content SDK):
// Use Pages Builder detection instead
import { isPageBuilder } from '@sitecore-content-sdk/nextjs';
if (isPageBuilder()) {
// Pages Builder specific code
}2. Personalization Handling Changes
JSS provided built-in personalization. Content SDK requires manual implementation:
Before (JSS 22.x):
// Automatic personalization via plugin
import { personalizePlugin } from '@sitecore-jss/sitecore-jss-nextjs/personalize';After (Content SDK):
// Manual personalization via SitecoreClient
const { layoutData } = await SitecoreClient.layout({
path,
language,
// Pass personalization context
personalization: {
segments: ['segment-a', 'segment-b'],
rules: customRules
}
});3. Linked Content Handling
Content SDK returns linked items as references, not embedded data:
Before (JSS 22.x):
// Linked items embedded in response
const authorName = fields.Author.fields.Name.value;After (Content SDK):
// Linked items require additional fetch
const authorRef = fields.Author;
const author = await SitecoreClient.item({
id: authorRef.id,
language: 'en'
});
const authorName = author.fields.Name.value;4. Script Updates
Update your package.json scripts:
| JSS 22.x Script | Content SDK Script |
|---|---|
start:connected | dev |
start:production | start |
jss deploy | npx sitecore-tools deploy |
jss scaffold | npx sitecore-tools project component scaffold |
Next.js 15 Integration Benefits
Content SDK is optimized for Next.js 15.3.1 and brings several performance improvements:
Improved Build Performance
Next.js 15 includes significant build optimizations that Content SDK leverages:
# Development with improved Fast Refresh
npm run dev
# Production builds are faster with optimized bundling
npm run buildEnhanced Caching with Pages Router
With Pages Router, you benefit from Next.js 15's improved ISR (Incremental Static Regeneration):
// pages/[[...path]].tsx
export const getStaticProps: GetStaticProps = async (context) => {
const { layoutData, dictionary } = await SitecoreClient.layout({
path,
language: locale,
siteName: config.siteName,
includeDictionary: true
});
return {
props: { layoutData, dictionary, locale },
revalidate: 60 // ISR - regenerate every 60 seconds
};
};React 18 Features
Content SDK with Next.js 15.3.1 fully supports React 18 features:
- Concurrent rendering – Improved rendering performance
- Automatic batching – Fewer re-renders for better performance
- Transitions – Smoother UI updates
- Suspense improvements – Better loading states (when using App Router in the future)
Why Not Next.js 16?
As of this writing, Content SDK 1.x requires Next.js 15.3.1. Next.js 16 support is expected in a future Content SDK release. Stick with 15.3.1 for the most stable and tested experience.
Migration Checklist
Use this checklist to track your migration progress:
Pre-Migration
- Audit all JSS package usages
- Document custom plugins and middleware
- List all components using
SitecoreJssPlaceholder - Identify personalization implementations
- Review linked content patterns
- Create backup of current state
- Document multi-tenant theme configurations
- List all supported languages and fallbacks
- Verify Next.js version is 15.3.1
Core Migration (Pages Router — Recommended)
- Remove JSS packages
- Install Content SDK packages
- Upgrade Next.js to 15.3.1 (if needed)
- Create
sitecore.config.ts - Create
sitecore.cli.config.ts - Create
component-map.ts - Update environment variables
- Migrate data fetching to SitecoreClient (keep
getStaticProps/getServerSideProps) - Update middleware to defineMiddleware
- Migrate all components (update imports and types)
- Update placeholder components
- Remove Experience Editor code
- Configure multi-tenant theme resolution (if applicable)
- Update multi-language routing (if applicable)
App Router Migration (OPTIONAL — Separate Future Initiative)
⚠️ Do not attempt during initial Content SDK migration. This should be a separate project with its own timeline after you've stabilized on Content SDK with Pages Router.
- Complete Content SDK migration with Pages Router first
- Validate production stability for 2-4 weeks minimum
- Train team on React Server Components
- Audit third-party library compatibility
- Plan dedicated App Router migration project
- Create
app/directory structure - Migrate pages from
pages/toapp/ - Convert
getStaticPropsto async Server Components - Add
'use client'directives where needed - Update
generateStaticParamsfor SSG - Implement
generateMetadatafor SEO - Extensive testing of all routes
- Remove old
pages/directory
Testing
- Run parallel comparison (JSS vs Content SDK)
- Validate all routes render correctly
- Test multi-language support
- Test all tenant themes render correctly
- Verify personalization (if applicable)
- Test error pages
- Validate sitemap generation for all languages
- Test redirects
- Performance benchmarking
- Test Pages Builder integration
Deployment
- Update deployment scripts
- Configure production environment variables
- Deploy to staging
- Run smoke tests for all tenants/languages
- Deploy to production
- Monitor for 2 weeks
Common Pitfalls and Solutions
Pitfall 1: Missing Components in Production
Symptom: Components render in development but show "Unknown component" in production.
Cause: Tree-shaking removed components not in component-map.ts.
Solution: Ensure all components are explicitly registered:
// Verify component-map.ts includes ALL components
export const componentMap: ComponentMap = {
// Don't forget ANY component, including rarely used ones
ErrorPage: ErrorPage,
NotFoundPage: NotFoundPage,
MaintenanceBanner: MaintenanceBanner,
// ...
};Pitfall 2: Stale Cache in Development
Symptom: Content changes in XM Cloud don't appear immediately.
Solution: Disable caching in development:
// sitecore.config.ts
export function createSitecoreConfig(): SitecoreConfig {
return {
// ...
cache: {
enabled: process.env.NODE_ENV === 'production',
ttl: 300
}
};
}Pitfall 3: Middleware Infinite Loops
Symptom: Pages never load, browser shows redirect loop.
Cause: Middleware not excluding static assets properly.
Solution: Update matcher config:
export const config = {
matcher: [
// Exclude all static paths
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|sitecore).*)',
]
};Pitfall 4: TypeScript Errors After Migration
Symptom: Type errors for field components.
Solution: Update type imports:
// Before
import { ComponentProps } from '@sitecore-jss/sitecore-jss-nextjs';
// After
import type {
ComponentRendering,
Field,
ImageField,
LinkField
} from '@sitecore-content-sdk/nextjs';Pitfall 5: Multi-Tenant Theme Not Loading
Symptom: Default theme shows for all tenants.
Solution: Verify middleware is setting tenant headers correctly and component map factory resolves theme:
// Debug in page component
const headersList = await headers();
console.log('Tenant:', headersList.get('x-tenant-id'));
console.log('Site:', headersList.get('x-sitecore-site'));Pitfall 6: Missing Dictionary Entries for Language
Symptom: Dictionary keys show instead of translated values.
Solution: Ensure dictionary is fetched with correct language and fallback is configured:
const { dictionary } = await SitecoreClient.dictionary({
siteName: config.siteName,
language: locale,
// Enable fallback to default language if key missing
includeFallback: true
});Conclusion
Migrating from Sitecore JSS 22.x to Content SDK is a manageable undertaking when you focus on the core migration path: Content SDK with Pages Router on Next.js 15.3.1. The benefits are substantial: smaller bundles, faster builds, better performance, and a cleaner architecture—all without the disruption of changing your routing architecture.
Key takeaways:
-
Stay on Pages Router – This is the recommended approach for most teams. You get all the benefits of Content SDK while keeping your familiar patterns.
-
Use Next.js 15.3.1 – Content SDK 1.x requires this version. Don't try to use Next.js 16 yet.
-
App Router is a separate initiative – If you decide to migrate to App Router in the future, treat it as its own project with dedicated timeline and resources. It's a significant architectural change, not a simple upgrade.
-
Don't combine migrations – Resist the temptation to "do it all at once." Migrating SDK + routing + React paradigms simultaneously multiplies your risk exponentially.
Recommended path:
Phase 1 (Now): JSS 22.x → Content SDK (Pages Router, Next.js 15.3.1)
- Stabilize in production (2-4 weeks minimum)
- Enjoy the benefits
Phase 2 (Later, Optional): Pages Router → App Router
- Only if specific features require it
- As a dedicated project with its own timeline
With JSS 22.x EOL approaching in June 2026, now is the time to plan your migration. Start with a proof of concept on a non-critical section of your site, validate the approach works for your specific use case, then execute the full migration with confidence.
The Content SDK represents Sitecore's vision for modern headless development. By focusing on the Pages Router migration first, you can capture the benefits quickly while keeping your options open for future architectural decisions.
Resources
- Sitecore Content SDK Documentation
- Content SDK GitHub Repository
- JSS GitHub Repository (for reference)
- Next.js 15 Documentation
- XM Cloud Pages Builder Guide
Have questions about migrating your specific implementation? Feel free to reach out—I'm happy to help troubleshoot your migration challenges.