Skip to content

Writing

Migrating Our JSS Next.js App to Sitecore Content SDK — Pitfalls We Hit First

Muhammad Ali Khan · 28 Oct 2026 · 08 Mins read

Migrating Our JSS Next.js App to Sitecore Content SDK — Pitfalls We Hit First

We stayed on JSS for the XM Cloud Next.js apps longer than the changelog wanted us to. On 29 October 2025 Sitecore published an SDK lifecycle: Content SDK 1.x was the supported SDK for XM Cloud, and JSS on that product line started phasing out. Content SDK 1.2, the next day, added a beta of the Next.js App Router. SitecoreAI itself was announced on 4 November 2025 as the evolution of XM Cloud, with no content migration for existing customers. The head application did not get that promise.

I am writing this in October 2026, after taking a JSS 22 Next.js app through the upgrade the current docs describe. The guide on my desk is "Upgrade JSS 22.0 Next.js apps to Content SDK 2.1". It targets Next.js 16 and React 19. An earlier guide covers JSS 22.9 to Content SDK 1.1 and still speaks defineMiddleware. Both are real. They are not interchangeable. The pitfalls below are the ones that cost us time first, in the order they showed up, not a second copy of Sitecore's upgrade checklist. If your package version differs, follow the guide Sitecore published for that version and use this as a list of things to test.


Pitfall 1: Treating the router change and the SDK change as one pull request

Content SDK runs on the Pages Router. That has been true since the first stable releases. App Router support arrived as a beta in Content SDK 1.2 at the end of October 2025, with server components, streaming, and nested layouts. The 2.1 guide, written for Next.js 16, documents withAppPlaceholder and a different proxy order for App Router apps. It is available. It is still a different architecture from the [[...path]].tsx app we had.

We tried to land the new SDK, the app/ directory, and the new data-fetching style in one branch. Editing was the casualty. The page builder posts to the SDK's editing endpoints, and those endpoints were half-migrated while the page was already a server component. Some routes hit the new editor path and some still hit pages/api.

The sequence that worked was dull. Move to Content SDK on the Pages Router, prove page-builder edit and preview, and only then spike App Router on one route. Sitecore's 1.2 changelog asked teams to try the beta. That is still the right verb for any route we cannot afford to have dark during a campaign.


Pitfall 2: Experience Editor assumptions baked into components

Content SDK does not support the Experience Editor, and it does not support preview launched from the Content Editor. Visual editing goes through the SitecoreAI page builder and uses field metadata. The upgrade guide is explicit about the removals: field.editable is gone, field components read field.metadata, EditMode.Chromes is gone, EditFrame and its button types are gone, the ExperienceEditor utility class is gone, and LayoutServiceContext no longer exposes editMode. HorizonEditor is renamed PagesEditor.

We had a hero that rendered extra outlines when layoutData.sitecore.context.pageState said we were editing, and a rich text wrapper that preferred field.editable over field.value so author scripts would run. Both were reasonable JSS patterns. Both are defects on Content SDK. The page builder already injects its metadata through the field components. A hand-rolled chrome fights it.

import { Text, type Field } from '@sitecore-content-sdk/nextjs';

type HeroFields = {
  heading: Field<string>;
  summary: Field<string>;
};

export const Hero = ({ fields }: { fields: HeroFields }) => (
  <header>
    <Text tag="h1" field={fields.heading} />
    <Text tag="p" field={fields.summary} />
  </header>
);

Where we need the mode, we read page.mode.isEditing, page.mode.isPreview, page.mode.isNormal, or page.mode.isDesignLibrary. A check against renderingType has to become isDesignLibrary, or Design Studio renders the full-page chrome around a single component.

Search the repository for editable, EditFrame, ExperienceEditor, pageEditing, and pageState before you start the package swap. The count is the size of this pitfall. Ours was larger than the estimate because shared packages in src/helpers had their own copies.


Pitfall 3: Environment variables that silently point at nothing

The 2.1 guide's rename table is short and easy to apply incompletely:

JSS nameContent SDK name
SITECORE_API_KEYNEXT_PUBLIC_SITECORE_API_KEY
SITECORE_API_HOSTNEXT_PUBLIC_SITECORE_API_HOST
SITECORE_SITE_NAMENEXT_PUBLIC_DEFAULT_SITE_NAME
DEFAULT_LANGUAGENEXT_PUBLIC_DEFAULT_LANGUAGE
JSS_EDITING_SECRETSITECORE_EDITING_SECRET

SITECORE_EDGE_URL goes away. Next.js apps use NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME so server and client see the same host. The guide's fallback is https://edge-platform.sitecorecloud.io. SITECORE_EDGE_CONTEXT_ID can stay server-side. Copying it to NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID exposes it to the browser. We did that for a day while debugging preview and then removed it. Preview did not need the context id on the client. The habit of prefixing every Sitecore variable with NEXT_PUBLIC_ did.

DISABLE_SSG_FETCH becomes GENERATE_STATIC_PATHS, and the meaning flips. We had DISABLE_SSG_FETCH=true in a non-production environment so builds would not walk the site. Copied forward as GENERATE_STATIC_PATHS=true, the build did the opposite and tried to pre-render everything. The variable name reads clearly if you forget the old one exists. Write the new value from the new meaning. Do not translate true with a find-and-replace.

The editing secret rename is the one that looks like a successful deploy. The site serves published pages from Edge with no secret at all. Editors open the page builder, the render request fails the secret check, and the iframe is blank. Our first theory was a Content Security Policy regression. It was JSS_EDITING_SECRET still set in the host, and SITECORE_EDITING_SECRET unset. Match the name the SDK reads, in every environment, including the local .env the rest of the team will copy.


Pitfall 4: The component map is a build artifact

JSS generated temp/componentBuilder from a bootstrap script and a watch process. Content SDK generates .sitecore/component-map through sitecore-tools project component generate-map, and the build script is expected to run that, then sitecore-tools project build, then next build. SitecoreProvider receives componentMap={components} imported from that map. The old componentFactory prop is gone.

The failure mode is unpleasant because next dev on a machine that ran the watch script works, and the production build renders "unknown component" for anything the map missed. Tree-shaking is not the mystery. The map is explicit. A component file that exists and is never registered will not appear.

We also kept importing temp/componentBuilder from a test helper, which failed the production type-check only after tsconfig paths were cleaned up. Delete the import. Point tests at .sitecore/component-map or at a fixture you control.

CI needs the tool in the pipeline, not only in a developer's dev script. The 2.1 guide's build script chains sitecore-tools:generate-map, sitecore-tools:build, and next:build for a reason. metadata.json and sites.json are produced under .sitecore by that build. The page builder uses the metadata. A pipeline that runs next build alone will serve a site that cannot be edited, which is a bad time to discover the gap.


Pitfall 5: Plugins that used to extend the page props

This was the largest silent behavior change. JSS apps accumulate plugins: src/lib/page-props-factory, layout and dictionary service factories, a site resolver, middleware plugins, GraphQL codegen, a config generator that writes temp/config. Content SDK deletes that layer. sitecore.config.ts is the runtime config. SitecoreClient fetches layout, dictionary, sitemap, error pages, and site information. scClient.resolveSite() replaces the site resolver. extract-path is imported from @sitecore-content-sdk/nextjs/utils.

Our multisite plugin set the site name from the host and added a theme object onto the context. After the upgrade the default site rendered for every host, with the right content and the wrong theme, because nothing threw. The resolver plugin was simply not called.

The fix is to extend SitecoreClient or to do the host-to-site mapping in the proxy, then pass the site through the client the SDK already uses. It is a small amount of code. It is easy to skip because the home page of the default site looks correct on a developer laptop that sends no host header worth mapping.

export default function proxy(req: NextRequest) {
  // defineProxy is the Content SDK 2.1 helper. Import it from the
  // path the matching create-content-sdk-app scaffold uses.
  // Pages Router order: multisite, redirects, personalize.
  // App Router order: locale, then those three.
  return defineProxy(multisite, redirects, personalize).exec(req);
}

The 2.1 guide says App Router runs locale, then multisite, then redirects, then personalize. Pages Router runs multisite, then redirects, then personalize. We copied an App Router snippet into a Pages Router app and locale handling swallowed a redirect.

The same guide also has a paragraph that says to delete src/proxy.ts and add src/middleware.ts with defineMiddleware. That name belongs to Content SDK 1.5 and earlier. The files from create-content-sdk-app at the version we installed were the ones that matched the package. I trust the scaffold over that contradictory note.

Component-level data fetching moved too. getStaticProps and getServerSideProps exported from a component become getComponentServerProps. If the function body cares whether it is in a server-side render, isServerSidePropsContext from @sitecore-content-sdk/nextjs/utils is the documented check. Leaving the old export names in place does not wire them up. The component renders without its extra data and the props are undefined. We saw that first on a component that loads a secondary nav from a related item.


Pitfall 6: GraphQL codegen and the import graph

The upgrade removes graphql-let, the codegen packages, .graphql-let.yml, and the introspection script. We had components importing generated types from graphql-types. Those imports fail before any page renders. The 2.1 guide also removes the graphql-types path from tsconfig.json.

We inlined the small queries we still needed and called anything custom through SitecoreClient in one module, so the app stopped importing generated documents.

SitecoreContext is gone as the type you pass around. The replacement is the Page object from SitecoreClient, and the React side is SitecoreProvider with useSitecore() instead of useSitecoreContext(). A context selector that read sitecoreContext.site.name needs to read the page. This is mechanical. It is also everywhere. I ran the codemod as a series of reviewed replacements, not one global rename, because context is a popular local variable name in our middleware and our tests.


Pitfall 7: SXA components, forms, and the lean template

Content SDK's starter is thinner than the JSS XM Cloud starter. The guide tells you to copy Image, Promo, and Title from the Content SDK starter and to re-apply your customizations. We assumed our JSS versions would compile against the new field types. Image was the one that looked fine and then dropped editing metadata, because it was still a fork that wrote field.editable into the DOM.

Forms need a presentation-details change, not just a package change. The guide's step is: open the page, Presentation, Details, Final Layout, and replace the BYOC wrapper used for the old form with the headless Forms Form control. Skip that and the form works in one environment, where someone fixed the item, and disappears in another, where the standard values still point at BYOC. We put the item change in the same deployment notes as the code change so a content package was part of the release.

headLinks, publicUrl, and PUBLIC_URL are removed, along with the rewrites that proxied /sitecore/api and media. A link helper that prefixed publicUrl started emitting undefined in front of media paths. Layout data already had absolute media URLs, so we stopped prefixing.


The order I would use on the next app

  1. Move the app to the JSS version that guide expects. For the 2.1 guide, that is JSS 22.0.0, which also means reading the Next.js 16 and React 19 notes before taking those majors.
  2. Scaffold a throwaway Content SDK app at the same version and the same prerendering choice. Use it as a file donor.
  3. Swap @sitecore-jss/sitecore-jss-nextjs for @sitecore-content-sdk/nextjs, the JSS CLI for @sitecore-content-sdk/cli, and delete @sitecore-jss/sitecore-jss-dev-tools. Bring across the analytics, events, and personalize packages if the app was on the Cloud SDK.
  4. Rename environment variables in every host, recompute GENERATE_STATIC_PATHS from scratch, and confirm the editing secret by opening the page builder.
  5. Point config at sitecore.config, put defineProxy in src/proxy.ts in the order that matches the router, and delete src/lib/middleware.
  6. Port site resolution and page-prop plugins onto SitecoreClient before deleting the plugin folders.
  7. Regenerate the component map, remove field.editable and EditFrame, and read page.mode.
  8. Update form presentation details, re-copy forked SXA primitives, and run sitecore-tools in CI ahead of next build.

On-prem XM and XP are outside this guide. Content SDK is the SitecoreAI SDK. The lifecycle note from October 2025 was about XM Cloud.


What "done" looked like for us

Done was not a green next build. Done was a page-builder session on a non-production site: place the hero, edit the heading inline, see metadata rather than a chrome iframe, save, preview, publish, and see the same heading on the public URL. Done included a second host header resolving to the second site, a form posting, and a component that uses getComponentServerProps still receiving its side data. Done included a failed build when a rendering was missing from the map, which we now treat as a feature.

The time went into the parts the guide cannot inventory: the helper that read pageState, the env file in a host nobody remembered, the multisite plugin that failed open, and the CI script that never called sitecore-tools. I would budget for those four even when the component count looks small.

Related notes