Contoprix
Back to blog

Next.js

How to Use a Headless CMS with Next.js App Router

Build a server-rendered Next.js App Router integration with Contoprix page delivery, dynamic routes, preview, static parameters, and signed webhook revalidation.

Contoprix

A headless CMS integration in Next.js has two jobs: retrieve structured content at the server boundary and turn that data into routes and components the application owns. The difficult part is not the first request. It is keeping routing, unpublished drafts, cache invalidation, localization, and failures consistent after the site grows.

This tutorial uses the current Contoprix packages with Next.js App Router. It keeps delivery credentials on the server, maps catch-all route segments to CMS page paths, and treats preview as a separate protected workflow.

The architecture#

The request path is deliberately short:

Published request flow
Browser path -> Next.js Server Component -> Contoprix delivery API -> React renderer -> HTML

Contoprix owns content models, editor drafts, pages, blocks, languages, and published versions. Next.js owns public routes, error handling, caching, HTML, interactivity, and deployment. That division is the practical meaning of a headless CMS architecture.

Normal page delivery reads published content with delivery:read. Draft preview is a different API and requires preview:read. Do not switch a public route into preview mode just because a visitor supplies a query parameter.

Install the current packages#

The current React and Next.js integrations target React 19 and Next.js 16. Install the core client, renderer, framework helpers, and shared types:

Terminal
npm install @contoprix/client @contoprix/react @contoprix/next @contoprix/types

Add private server configuration:

.env.local
CONTOPRIX_BASE_URL=https://cms.example.com
CONTOPRIX_DELIVERY_KEY=replace-with-your-delivery-key
CONTOPRIX_WEBHOOK_SECRET=replace-with-your-webhook-secret

CONTOPRIX_BASE_URL is the API origin, without an /api suffix. The delivery key must belong to the website being rendered. Never give a credential a NEXT_PUBLIC_ prefix; Next.js exposes those variables to browser code.

The installation guide and SDK configuration guide document the supported packages and authentication options.

Build the rendering boundary#

Contoprix delivers page blocks; your application supplies their React implementations. Keep that mapping in one registry and render it through PageRenderer:

src/contoprix/ContoprixRenderer.tsx
"use client";

import { PageRenderer } from "@contoprix/react/client";
import type { ContoprixPage } from "@contoprix/types";

import components from "./components";
import { schemas } from "./schema";

export function ContoprixRenderer({ page }: { page: ContoprixPage }) {
  return <PageRenderer page={page} components={components} schemas={schemas} />;
}

The client boundary is for React rendering behavior, not content retrieval. Fetch the page in a Server Component and pass the result down. The component registry remains application code, so accessibility, responsive behavior, styling, loading states, and analytics stay under frontend control.

Fetch the root page#

The root route is clearer when it is handled separately. createContoprixClient() reads the documented server environment variables, and pages.get() retrieves the published home page:

app/page.tsx
import { createContoprixClient } from "@contoprix/next/server";

import { ContoprixRenderer } from "@/contoprix/ContoprixRenderer";

export const revalidate = 60;

export default async function HomePage() {
  const client = createContoprixClient();
  const page = await client.pages.get({ languageCode: "en" });

  return <ContoprixRenderer page={page} />;
}

In a production application, translate only a genuine delivery 404 into Next.js notFound(). Authentication, configuration, timeout, and upstream availability errors should reach your normal error handling and monitoring instead of being disguised as missing content.

Map dynamic App Router paths#

Create app/[...slug]/page.tsx for non-root CMS pages. In Next.js 16, params is a promise and must be awaited.

app/[...slug]/page.tsx
import { notFound } from "next/navigation";
import { getContoprixPage } from "@contoprix/next/server";

import { ContoprixRenderer } from "@/contoprix/ContoprixRenderer";
import { isNotFoundError } from "@/lib/contoprix/errors";

export const revalidate = 60;
export const dynamicParams = true;

export default async function CmsPage({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  const path = `/${slug.join("/")}`;

  const page = await getContoprixPage({
    slug: path,
    languageCode: "en",
  }).catch((error) => {
    if (isNotFoundError(error)) return null;
    throw error;
  });

  if (!page) notFound();

  return <ContoprixRenderer page={page} />;
}

isNotFoundError above represents the application’s own error-classification helper, not a Contoprix export. The Contoprix demo application implements that boundary by checking the SDK error status. The important rule is to preserve non-404 failures.

Pass the full public path, such as /company/team, rather than only the final segment. The SDK handles delivery-route encoding. The detailed routing guide covers root routes, nested paths, and navigation.

Generate known routes at build time#

For a manageable page set, generateContoprixStaticParams() reads the published sitemap and converts its slugs to catch-all parameters:

app/[...slug]/page.tsx
import { generateContoprixStaticParams } from "@contoprix/next/server";

export async function generateStaticParams() {
  return generateContoprixStaticParams({ languageCode: "en" });
}

Keep dynamicParams = true if editors may publish a new path without a frontend rebuild. Static parameters improve build-time coverage; they do not replace a cache-refresh strategy.

Keep draft preview separate#

Preview loads a draft by page ID rather than a published page by URL. After your application authorizes the editor, call the preview helper from a dynamic, non-publicly cached route:

Draft retrieval after authorization
import { getContoprixPreviewPage } from "@contoprix/next/server";

const page = await getContoprixPreviewPage({
  pageId,
  languageCode: "en",
});

The preview client requires preview:read, and the route must enforce real authorization. A hard-to-guess page ID is not access control. Keep preview credentials on the server, avoid public CDN caching, and visually distinguish preview from the live site. See Visual Editing and Preview for the full protected flow.

Revalidate after publishing#

A time-based revalidate interval is a useful fallback. A signed webhook can make published changes visible sooner:

app/api/contoprix/webhook/route.ts
import { handleContoprixWebhook } from "@contoprix/next/server";

export const runtime = "nodejs";

export async function POST(request: Request) {
  return handleContoprixWebhook(request, {
    secret: process.env.CONTOPRIX_WEBHOOK_SECRET!,
  });
}

The current handler reads the raw request body, verifies X-Contoprix-Signature with HMAC-SHA256, and then revalidates the relevant page paths or content tags. It returns 401 for an invalid signature. Do not write a webhook endpoint that trusts a page slug without verifying the signature.

Saving a draft does not alter published delivery. Configure revalidation around the publish workflow described in the publishing guide.

Localization is explicit#

Pass languageCode on delivery, preview, sitemap, and static-parameter calls when the site is localized. A published English version does not imply a published French version. Map locale-aware application paths to the same explicit language code used by Contoprix, and decide how your application handles missing translations.

Production checklist#

Before launching a CMS-backed App Router route, verify:

  1. Delivery, preview, and webhook credentials are server-only and have the minimum required scopes.
  2. Root and nested browser paths map to the correct Contoprix public paths.
  3. Only real delivery 404s become notFound().
  4. Every published component code has an intentional renderer or reviewed schema fallback.
  5. Preview requires editor authorization and cannot enter public caches.
  6. Publication triggers a signed webhook, a suitable revalidation interval, or both.
  7. Every supported language has independently published content.
  8. A direct request returns useful server-rendered HTML before client JavaScript runs.

The /nextjs-cms page summarizes the integration capabilities. Use the SDK reference for delivery calls and dynamic-pages guide when the URL represents a content entry rather than a composed CMS page.