Sitecore Content SDK: Next.js App Router vs Page Router
A side-by-side look at how the Content SDK integrates with each Next.js router — same components, same backend, different patterns.
A side-by-side look at how the Content SDK integrates with each Next.js router — same components, same backend, different patterns.
Start typing to search...
If you're building on Sitecore AI with the new Content SDK, that's the first decision you'll face — and the internet is full of opinions but short on actual side-by-side comparisons of how the SDK works in each. So I built the same site twice.
Same components, same content, same Sitecore AI backend — two different frontends. After a few days of building, breaking, and fixing things, I came away with a clear picture: the Content SDK itself is router-agnostic. The differences you'll hit are in how each router handles data fetching, i18n plumbing, and the client/server boundary. That's what this post covers.
Both projects were scaffolded using npx create-content-sdk-app with Content SDK v1.4. They point to the same Sitecore XM Cloud instance, so they render identical content. App Router runs on port 3000, Page Router on 3001. I built matching components in both and then started poking at the edges.
The project structure difference is the obvious one:
# App Router
src/app/[site]/[locale]/[[...path]]/page.tsx
src/app/api/siteinfo/route.ts
src/components/
# Page Router
src/pages/[[...path]].tsx
src/pages/api/siteinfo.ts
src/components/The src/components/ folder is the same in both. That's important — more on this later.
This surprised me a bit. When you're building a Sitecore component with the Content SDK, the code is nearly the same regardless of router. You still use <Text>, <Image>, <RichText> from the SDK. You still get fields, rendering, and params as props. Your component still lives in src/components/ and gets auto-registered via the component map.
Here's a simplified version of our HeroST component. In both routers, it looks like this:
import { Text, Image } from '@sitecore-content-sdk/nextjs';
export default function HeroST({ fields }) {
return (
<section>
<Text field={fields?.title} tag="h1" />
<Text field={fields?.subtitle} tag="p" />
<Image field={fields?.backgroundImage} />
</section>
);
}Component variants work the same way too. We had five variants for HeroST (Default, Centered, Right, SplitScreen, Stacked), and in both projects they're just named exports:
export const Default = ({ fields }) => { /* ... */ };
export const Centered = ({ fields }) => { /* ... */ };
export const Right = ({ fields }) => { /* ... */ };
// etc.
export default Default;The SDK reads params.FieldNames from Sitecore and matches it to the named export. Same mechanism, both routers.
This is where the real architectural difference lives.
App Router uses async Server Components. The page itself is an async function that fetches data directly:
// App Router: src/app/[site]/[locale]/[[...path]]/page.tsx
export default async function Page({ params }) {
const { site, locale, path } = await params;
const page = await client.getPage(path ?? [], { site, locale });
if (!page) {
notFound();
}
const componentProps = await client.getComponentData(page.layout, {}, components);
return (
<Providers page={page} componentProps={componentProps}>
<Layout page={page} />
</Providers>
);
}Page Router uses the traditional getServerSideProps pattern — a separate exported function that runs on the server and passes data down as props:
// Page Router: src/pages/[[...path]].tsx
const SitecorePage = ({ page, componentProps }) => {
return (
<Providers componentProps={componentProps} page={page}>
<Layout page={page} />
</Providers>
);
};
export const getServerSideProps = async (context) => {
const path = extractPath(context);
const page = await client.getPage(path, { locale: context.locale });
return {
props: {
page,
dictionary: await client.getDictionary({ site: page.siteName, locale: page.locale }),
componentProps: await client.getComponentData(page.layout, context, components),
},
notFound: !page,
};
};Both end up in the same place — your layout gets the page data and renders components. But the where is different: App Router does it inline, Page Router does it in a separate function.
Sitecore dictionary items work in both routers, but through different libraries:
App Router uses next-intl:
'use client';
import { useTranslations, useMessages } from 'next-intl';
const HeaderST = () => {
const messages = useMessages();
const siteName = Object.keys(messages)[0] || 'sync';
const t = useTranslations(siteName);
return <button>{t('Signup_Form_Button_Label')}</button>;
};Page Router uses next-localization:
import { useI18n } from 'next-localization';
const HeaderST = () => {
const { t } = useI18n();
return <button>{t('Signup_Form_Button_Label')}</button>;
};Both render the same button label from the same Sitecore dictionary item. The dictionary key is identical. The plumbing behind the scenes is different — next-intl fetches the dictionary server-side in src/i18n/request.ts and provides it via NextIntlClientProvider, while next-localization gets it from getServerSideProps and wraps the app with <I18nProvider> in _app.tsx.
Notice the 'use client' directive on the App Router version. Any component that uses hooks (useState, useTranslations, etc.) needs this in App Router. In Page Router, everything is client-side by default so you never think about it.
We built a /api/sites endpoint that calls client.getData() with a raw GraphQL query against Sitecore Edge. Same query, same data — two very different file structures.
App Router — one function per HTTP method, each a named export:
// src/app/api/sites/route.ts
import { NextResponse } from 'next/server';
import client from 'src/lib/sitecore-client';
const SITE_QUERY = `
query {
site {
siteInfoCollection { name hostname language }
}
}
`;
export async function GET() {
const result = await client.getData(SITE_QUERY);
return NextResponse.json({ sites: result.site.siteInfoCollection });
}
export async function POST(request) {
const body = await request.json();
// handle POST...
}Page Router — one default handler, branching on req.method:
// src/pages/api/sites.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import client from 'lib/sitecore-client';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const result = await client.getData(SITE_QUERY);
return res.status(200).json({ sites: result.site.siteInfoCollection });
}
if (req.method === 'POST') {
// handle POST...
}
return res.status(405).json({ error: 'Method not allowed' });
}The App Router pattern is cleaner for multi-method endpoints — no if/switch branching, and each method can be reasoned about independently. The Page Router pattern is more familiar if you've used Express.
Also worth noting the response APIs: App Router uses NextResponse.json() (Web standard), Page Router uses res.status(200).json() (Express-like).
This is where App Router genuinely shines, and it's probably the easiest thing to demo to a skeptical team.
App Router loading: Just drop a loading.tsx file in your route folder. That's it. Next.js automatically wraps your page in a Suspense boundary and shows this component while the async page is streaming in.
// src/app/[site]/[locale]/[[...path]]/loading.tsx
export default function Loading() {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="spinner" />
<p>Loading page...</p>
</div>
);
}No imports. No wiring. No useState. Just a file.
Page Router has no equivalent. You'd need to manually track loading state with useState and useEffect, or hook into router.events for route change detection. It's doable, but it's ceremony.
Error boundaries tell a similar story. App Router gives you per-route error.tsx with a reset() function that can recover without a full page reload:
// src/app/[site]/[locale]/[[...path]]/error.tsx
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}Page Router gives you a single global _error.tsx. One error UI for the entire app. No reset() — if something breaks, the user refreshes the page.
Here's something that bit me and is worth calling out specifically.
In App Router, if your component uses any React hooks — useState, useEffect, useTranslations, whatever — you need the 'use client' directive at the top of the file. That means the component runs in the browser, not on the server.
This creates a real constraint: you cannot mix server-side data fetching and client hooks in the same file.
I tried to put a getComponentServerProps export (the SDK's component-level server data fetching) in the same file as a 'use client' component. Next.js threw an error immediately — server functions can't be exported from client modules.
In Page Router, this isn't a problem at all. Your component and its getComponentServerProps export live happily in the same file because there's no client/server boundary.
For App Router, when you need both client interactivity AND server-side data fetching in a component, you need a different approach. We ended up creating API routes that wrap the GraphQL calls, and the client components fetch from those. It works well and keeps things clean, but it's an extra layer that Page Router doesn't need.
I want to stress this because it's the thing that surprised me most. The SitecoreClient from @sitecore-content-sdk/nextjs works identically in both routers:
import client from 'lib/sitecore-client';
// Works in both routers — same call, same result
const page = await client.getPage(path, { site, locale });
const dictionary = await client.getDictionary({ site, locale });
const result = await client.getData(graphqlQuery);client.getPage(), client.getDictionary(), client.getData() — all the same. The SDK abstracts away the Sitecore Edge connection, GraphQL queries, and content resolution. Your choice of router doesn't change any of this.
After building both, here's my honest take:
'use client' mental modelclient.getData() and the SDK APIs are router-agnosticThe Content SDK team did a good job making the component layer feel consistent. The differences you'll encounter are really Next.js differences — routing patterns, data fetching conventions, and the client/server boundary — not Sitecore differences.
| Feature | App Router | Page Router |
|---|---|---|
| Components | Same | Same |
| Sitecore field components | <Text>, <Image>, <RichText> | Same |
| Component variants | Named exports | Same |
| Dictionary | next-intl + 'use client' | next-localization |
| Data fetching | Async Server Components | getServerSideProps |
| API routes | route.ts + named exports | Single handler + req.method |
| GraphQL | client.getData() | Same |
| Loading states | Built-in loading.tsx | Manual |
| Error boundaries | Per-route error.tsx + reset() | Global _error.tsx |
'use client' | Required for hooks | Not needed |