Skip to content

Migrating from Sitecore JSS 22.x to Content SDK: The Complete Guide

32 min read6356 words

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:

  1. JSS SDK → Content SDK (the Sitecore SDK change) - Required migration
  2. 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

#SectionDescription
1Why Migrate Now?EOL deadline and performance benefits
2Understanding the Architectural ShiftJSS vs Content SDK architecture
3Choosing Your Migration PathPages Router vs App Router options
4Step-by-Step Migration Guide8 detailed migration steps
5Multi-Tenant ArchitectureDifferent themes per tenant
6Multi-Language Migrationi18n and locale handling
7Breaking ChangesCritical changes to address
8Next.js 15 BenefitsPerformance improvements
9Migration ChecklistPre/Post migration tasks
10Common PitfallsSolutions 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:

MetricJSS 22.xContent SDKImprovement
Bundle SizeBaseline49% smaller-49%
File CountBaseline81% fewer-81%
Lines of CodeBaseline39% less-39%
API Response TimeBaseline40-80% fasterUp 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 fetching

Content 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 SitecoreClient

Content 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 structure
  • getStaticProps / getServerSideProps patterns
  • 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 effort

Why stay on Pages Router?

  1. Proven stability – Pages Router is mature and battle-tested
  2. Lower risk – No need to learn new paradigms during a critical migration
  3. Faster time-to-value – Get Content SDK benefits without additional complexity
  4. Team productivity – Your team already knows Pages Router patterns
  5. 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-deps

Verify 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.1 for 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 VariableContent SDK VariableNotes
SITECORE_API_HOSTSITECORE_API_URLSame purpose, renamed
JSS_EDITING_SECRETSITECORE_EDITING_SECRETSame 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 --watch

Step 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/getServerSideProps patterns and simply replace the JSS service calls with SitecoreClient. 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 layoutServiceFactory with SitecoreClient.layout()
  • Replace dictionaryServiceFactory with includeDictionary: true option
  • Replace sitemapService with SitecoreClient.sitemap()
  • Update type imports from @sitecore-content-sdk/nextjs
  • Keep getStaticProps/getStaticPaths patterns 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.ts

Theme 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 ScriptContent SDK Script
start:connecteddev
start:productionstart
jss deploynpx sitecore-tools deploy
jss scaffoldnpx 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 build

Enhanced 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/ to app/
  • Convert getStaticProps to async Server Components
  • Add 'use client' directives where needed
  • Update generateStaticParams for SSG
  • Implement generateMetadata for 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:

  1. 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.

  2. Use Next.js 15.3.1 – Content SDK 1.x requires this version. Don't try to use Next.js 16 yet.

  3. 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.

  4. 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


Have questions about migrating your specific implementation? Feel free to reach out—I'm happy to help troubleshoot your migration challenges.