Skip to content

Render content on a server

This page builds the site from Render content in a static site again, with a server in front of it: a Next.js App Router app that resolves each page on the server and sends it as HTML. The renderers are the same files. What changes is where the page is resolved and which key does it.

For a finished site built this way, see the northwind-coffee-ssr sample. It is the same site, content model and seed content as the static northwind-coffee sample, so the two read as one comparison.

You need Node 20 or newer, and an ebitex organization with at least one published page. Set up your organization covers signing up and getting content published.

A static site is fully supported and is often the right choice. A server buys you these:

  • Content in the delivered HTML. A crawler, a link preview and a reader with JavaScript turned off all get the page, and the first paint needs no request from the browser.
  • Links in the HTML. A document that references another page can carry that page’s published path, so the renderer writes a real link on the server. Anything a browser has to look up after the page loads never reaches the HTML.
  • Real status codes. A missing page is a 404 and a redirect is a 308, rather than a page that loads and then changes its mind.
  • A private key. The delivery key stays on the server, so it needs no origin restrictions and is never published.
  • Per-request decisions. The server can personalize from a cookie before sending the first byte.

And it costs these:

  • You run a server. A static bundle can be served from any CDN; this needs a process.
  • The browser holds no key. Anything interactive that queries Content from the browser, such as a filterable list, has to go through a route of your own.
  • Freshness is your job. A long-running server caches what it fetches until you tell it otherwise. See Caching and invalidation.

In Content, open Settings → API Keys, name the key, and leave Browser-safe key turned off. Copy the key: it is shown once.

A browser-safe key does not work here. It only answers requests from the origins you listed, and a server sends no Origin header, so every request is refused with origin_denied.

Terminal window
npx create-next-app@latest my-site
cd my-site
npm install @ebitex/content-sdk server-only

Put your settings in .env.local:

Terminal window
# Never give this a NEXT_PUBLIC_ prefix. That prefix inlines a value into the browser bundle.
CONTENT_DELIVERY_KEY=<your server-side key>
# Your site's root node id, from Content → Configure → Sites.
# Optional while your organization publishes exactly one site.
CONTENT_SITE_ID=<root node id>

Create lib/content.ts:

lib/content.ts
// `lib/content.ts` in a Next.js App Router project: the one place the server builds its Content
// client. Pages, layouts and route handlers all import it.
import { createContentClient, sharedContentClient, type ContentClient } from '@ebitex/content-sdk'
// Next.js provides `process.env`. Declared here only so this file compiles on its own.
declare const process: { env: Record<string, string | undefined> }
const siteId = process.env.CONTENT_SITE_ID
// `sharedContentClient` keeps one client for the life of the server process. A module-level `const`
// is not enough: Next.js evaluates a page's modules and a route handler's modules separately, so a
// `const` becomes two clients with two caches, and clearing one from a route handler leaves the
// cache your pages read untouched.
//
// The factory returns `null` when no key is configured, and that answer is remembered too.
export const content: ContentClient | null = sharedContentClient(() =>
process.env.CONTENT_DELIVERY_KEY
? createContentClient({
// A server-side key. It never leaves this process, so it needs no origin restrictions.
apiKey: process.env.CONTENT_DELIVERY_KEY,
// A server has no page hostname to match a site against, so name the site. The function
// form, rather than the id as a string, lets the client read the site's own configuration,
// including whether its addresses carry a locale.
site: siteId ? (sites) => sites.find((site) => site.rootNodeId === siteId) : undefined,
// Every reference to another page arrives carrying that page's published path, so a
// renderer can write the link straight into the HTML.
referencePaths: true,
})
: null,
)

Add import 'server-only' as the first line of this file. The server-only package turns an import of this module from a client component into a build error, so the key cannot reach a browser by accident.

Why sharedContentClient. The client holds a cache every request shares, so the server wants exactly one. A module-level const looks like one and is not: a framework can evaluate the same module more than once in one process, and Next.js does, once for pages and once for route handlers. Each copy gets its own cache. Nothing reports the problem, because both clients work. It shows up later, when a route handler clears its cache after a publish and your pages keep serving the old content from theirs. sharedContentClient pins the client to the process, so every copy of the module gets the same one. In a browser it changes nothing.

Sharing one client between visitors is safe. The personalization context is part of every cache key, so one visitor’s personalized content is never served to another.

Create app/[[...path]]/page.tsx, one optional catch-all route for every page:

app/[[...path]]/page.tsx
// `app/[[...path]]/page.tsx`: one optional catch-all route serves every page the CMS publishes. It
// is a server component, so the page is resolved before any HTML is sent.
import { content } from './nextjs-content.js'
import { ContentRoot } from './nextjs-content-root.js'
// From `next/navigation` in your app. Declared here so this file compiles without Next.js installed.
declare function notFound(): never
declare function permanentRedirect(path: string): never
const SITE_NAME = 'Example site'
type PageProps = { params: Promise<{ path?: string[] }> }
function requestPath(segments: string[] | undefined): string {
return '/' + (segments ?? []).join('/')
}
// The page's <title>. Next.js calls this before it renders the page, so the title comes from the
// resolve rather than from a renderer. `Page` below resolves the same path again at no extra cost:
// the client shares one request between both calls.
export async function generateMetadata({ params }: PageProps): Promise<{ title: string }> {
if (!content) return { title: SITE_NAME }
const { path } = await params
try {
const result = await content.resolveLocation(requestPath(path))
// The title the CMS computed for this address. It is optional, so keep a fallback.
return { title: result.kind === 'presentation' ? (result.title ?? SITE_NAME) : SITE_NAME }
} catch {
// If the Delivery API cannot be reached, `Page` hits the same error and your error page
// explains it. Failing here instead would skip that page entirely.
return { title: SITE_NAME }
}
}
export default async function Page({ params }: PageProps) {
if (!content) return <p>Set CONTENT_DELIVERY_KEY to a delivery key from your ebitex organization.</p>
const { path } = await params
const result = await content.resolveLocation(requestPath(path))
// The SDK reports a redirect or a missing page and does nothing about either. On a server, both
// become real status codes: 308 and 404.
if (result.kind === 'redirect') permanentRedirect(result.targetPath)
if (result.kind === 'notFound') notFound()
// Only the result crosses into the client component, and it is plain JSON.
return <ContentRoot result={result} />
}

resolveLocation returns one of three results:

kind What it means What the page does
presentation A page is published at this address Renders it
redirect The address moved, for example a renamed page’s old slug Sends a 308 to targetPath
notFound Nothing is published here Sends a 404

Any other failure, such as a bad key or an unreachable API, is thrown as a ContentDeliveryError, and your error page handles it.

On a server the client resolves each page to full depth in one request, so everything the page references arrives with it and nothing is left to fetch in the browser.

Create app/content-root.tsx:

app/content-root.tsx
'use client'
// `app/content-root.tsx`: the client boundary. The renderer map is a map of components, which a
// server component cannot pass to a client one, so the map lives on this side and only the
// resolved result crosses.
import type { ExperienceResult } from '@ebitex/content-sdk'
import { ContentProvider, Experience, type Renderers } from '@ebitex/content-sdk/react'
import Hero from './hero.js'
// `renderersFromGlob` needs Vite, so a Next.js app lists its renderers. Each key is still the
// Template's external id.
const renderers: Renderers = {
hero: Hero,
}
export function ContentRoot({ result }: { result: ExperienceResult }) {
return (
// `fallback` replaces the SDK's developer panel for content that cannot be rendered, so the
// panel never reaches the HTML you ship.
<ContentProvider renderers={renderers} fallback={() => null}>
<Experience result={result} />
</ContentProvider>
)
}

This is the server rendering path. <Experience result> renders a result you already hold: it needs no client and makes no request, so Next.js renders it to HTML on the server and then hydrates the same tree in the browser. <ContentProvider> takes no client here for the same reason.

The renderer map has to live in a client component. It is a map of component functions, and a server component can only pass plain data to a client one. The resolved result is plain JSON, so that is what crosses.

renderersFromGlob needs Vite, so this map lists each renderer by hand. The renderer itself is the same file the static site uses, presentations/hero.tsx:

presentations/hero.tsx
// The renderer for the Template whose external id is `hero`. The file name is the registration and
// the default export is the renderer.
import type { RichTextValue } from '@ebitex/content-sdk'
import { RichText, type PresentationRenderer } from '@ebitex/content-sdk/react'
// The fields of the Contract this Template presents, keyed by each field's external id. Written by
// hand here; generated types can replace it.
interface Statement {
heading: string
body?: RichTextValue
}
// The Template's own settings, which an author chooses each time they use it.
interface HeroSettings {
align?: 'center' | 'start'
}
// `component.content` is always present when a renderer is called. Content that cannot be resolved
// renders a fallback instead, so a renderer never checks for it.
const Hero: PresentationRenderer<Statement, HeroSettings> = ({ component, settings }) => (
<section style={{ textAlign: settings.align ?? 'center' }}>
<h1>{component.content.heading}</h1>
<RichText fragments={component.content.body} />
</section>
)
export default Hero

What renders on the server, and what does not

Section titled “What renders on the server, and what does not”

Anything that resolves content inside an effect renders nothing on the server, because no effect runs during a server render. Nothing fails either: those components simply render empty HTML.

Renders HTML on the server Renders nothing on the server
<Experience result> <Experience path>, which resolves in an effect
<PresentationList> and <RichText> useNavigation, for the same reason
<StandaloneComponent> <EditorEntryCta>

Resolve on the server and pass the result in. Don’t expect HTML from the hook-driven forms.

Next.js calls generateMetadata before it renders the page, so the title can’t come from a renderer. It comes from the resolve: result.title is the title the CMS computed for this address.

Content takes that title from the field a Contract declares as its title field, or from an override set where the content is used at this address. If no Contract in the chain declares a title field, the title is the Experience node’s own name, which is an editor’s internal label. result.titleSource tells you which: 'content' or 'name'. If you would rather not show an internal label, fall back to your own title when it is 'name'.

Both members are optional, so always keep a fallback, as result.title ?? SITE_NAME does in the example. A result built in the browser for a live-preview draft has no title.

Resolving the page in generateMetadata and again in Page costs one request. The client shares a request between callers asking for the same location with the same options. If you pass options, such as a personalization context, build them in one function that both callers use, because a different context is a different request.

A description, a social image and structured data have no field declared for them in the CMS, so those are your app’s to build from result.presentation.

The client caches resolved results in memory: the 50 most recently used, with no expiry. In a browser tab that is invisible, because the tab closes. In a server process that lives for days, a small site never evicts anything, so it never sees a publish. The page is not stale for a while; it is stale until something clears the cache.

Clear it after you publish. Next.js has a render cache of its own, so a revalidation route has to clear both. Create app/api/revalidate/route.ts:

app/api/revalidate/route.ts
// `app/api/revalidate/route.ts`: how a publish reaches a long-running server. Call it after you
// publish, from a deploy step, a scheduled job, or by hand.
import { content } from './nextjs-content.js'
// From `next/cache` in your app, and provided by Node.js. Declared here so this file compiles on its
// own.
declare function revalidatePath(path: string, type?: 'layout' | 'page'): void
declare const process: { env: Record<string, string | undefined> }
export async function POST(request: Request) {
const secret = process.env.REVALIDATE_SECRET
if (secret && request.headers.get('x-revalidate-secret') !== secret) {
return new Response(null, { status: 403 })
}
// Two caches that know nothing about each other, and both have to be cleared. The client's cache
// has no expiry, so without `invalidate()` the server keeps what it first fetched. Without
// `revalidatePath`, Next.js keeps serving its last render and never asks the client at all.
content?.invalidate()
revalidatePath('/', 'layout')
return Response.json({ revalidated: true })
}

Clearing only one of the two looks like it works and does not. Clear only the client’s and Next.js may never ask for anything. Clear only Next.js’s and it re-renders from content the client still holds.

Two other options, depending on your setup:

  • A CDN in front of the site. If a CDN does the caching that matters, turn the client’s cache down or off: cache: { maxEntries: 1 } or cache: false in createContentClient.
  • Per-request personalization. Pass the context per call, content.resolveLocation(path, { context: { audiences } }), rather than configuring context on the client. A context function on a shared client runs with no way of knowing whose request it is. A page that reads a cookie is per visitor, so a shared cache must not store it.