Skip to main content

Render on the server with the Next.js App Router

Render a template on the server in a Next.js App Router app, with its content, styles, title and meta tags in the first HTML response. Use this page when your app uses the App Router and search engines or social previews must see the template. The example on this page is the Blog post template, rendering AI 101 at /blog/ai-101 in the Blog app on http://localhost:3000.

Prerequisites​

  • The Studio SDK is set up, and lib/contentstack.ts exports the object studioSdk.init returned as sdk. See Install the Studio SDK.
  • StudioInit renders in app/layout.tsx, so the init runs in the browser. See Make sure the init runs in the browser.
  • Your components are registered in studio/register-components.ts, imported as @/studio/register-components. See Register a component.
  • The catch-all template route is app/[[...slug]]/page.tsx. See Wire template preview routes.
  • You've read Render on the server. The steps below place its steps in the App Router, and link back to it for why each one is needed.

What this is and what you get​

In the App Router, a Server Component fetches the template, and a client component renders it. <StudioComponent /> is a client component, and Next.js renders it to HTML on the server too.

The route has two files:

  • app/[[...slug]]/page.tsx, a Server Component. It fetches the template, adds its styles, and writes its title and meta tags.
  • app/[[...slug]]/template-renderer.tsx, a client component. It imports your registrations and renders <StudioComponent />.

You get HTML with the template's content, styles, title and meta tags in the first response, as on Render on the server.

Faster ways to do this​

  • Skill. Ask your agent to set up server rendering on the App Router, and it uses configure-csr-vs-ssr. See What are skills?

Fetch the template in a Server Component​

In page.tsx, build url from the route's slug segments: a / followed by the segments joined with /. For /blog/ai-101, the segments are blog and ai-101.

Pass the page's searchParams as searchQuery, turned into a string. Don't read or build the query parameters yourself. Studio and the SDK agree on their names. See Forward the request's query string as searchQuery.

In Next.js 15, params and searchParams are promises, so await them.

When no template matches, fetchCompositionData rejects. It never resolves with an empty result. Catch the error, and return null when isCompositionNotFound(error) is true. Rethrow every other error.

isCompositionNotFound comes from @contentstack/studio-react. It's true for the six not-found codes, such as NO_COMPOSITION_FOR_URL and URL_MATCHED_NO_ENTRY. A rethrown error answers 500, so caches and search engines don't drop a page that exists.

generateMetadata needs the template too. Wrap the fetch in React's cache, so one request fetches it once.

// app/[[...slug]]/page.tsx
import { cache } from "react";
import { isCompositionNotFound } from "@contentstack/studio-react";
import { sdk } from "@/lib/contentstack";

type Props = {
params: Promise<{ slug?: string[] }>;
searchParams: Promise<Record<string, string>>;
};

const getTemplate = cache(async (url: string, searchQuery: string) => {
try {
return await sdk.fetchCompositionData({ url, searchQuery });
} catch (error) {
if (isCompositionNotFound(error)) return null;
throw error;
}
});

async function loadTemplate({ params, searchParams }: Props) {
const { slug = [] } = await params;
const url = "/" + slug.join("/");
const searchQuery = new URLSearchParams(await searchParams).toString();
return getTemplate(url, searchQuery);
}

The page calls notFound() when loadTemplate returns null, and Next.js answers 404. The whole file is under Set the title and meta tags with generateMetadata.

Render the template from a client component​

<StudioComponent /> is a client component. Render it from a small client component of your own, TemplateRenderer, and pass it the specOptions from page.tsx as a prop.

specOptions is plain data, so Next.js can send it to the client component. That holds while you don't pass fetchComposition or fetchTemplateEntry in the fetch options. They're copied into specOptions, and a function can't cross to a client component.

Import your registrations in TemplateRenderer. A registration imported only in page.tsx doesn't reach the browser. The server HTML looks right, then the page shows Component Loading Error once it hydrates.

Don't fetch again with useCompositionData in the client component. Render the specOptions you were passed.

// app/[[...slug]]/template-renderer.tsx
"use client";

import { StudioComponent, type StudioComponentSpecOptions } from "@contentstack/studio-react";
import "@/studio/register-components";

export function TemplateRenderer({ specOptions }: { specOptions: StudioComponentSpecOptions }) {
return <StudioComponent specOptions={specOptions} />;
}

The App Router route for /blog/ai-101 in two files. page.tsx, a Server Component, calls fetchCompositionData, getSSRStyles and generateMetadata, and passes specOptions to template-renderer.tsx, a client component, which imports @/studio/register-components and renders StudioComponent. Together they produce the first HTML response, with AI 101's content, styles, title and meta tags.

Add the template's styles in the Server Component​

getSSRStyles(specOptions.spec) returns the Design panel styles and the design-token variables as CSS text. Call it in page.tsx, and render the CSS in a <style> element before TemplateRenderer.

Don't call it in the client component. The browser computes different CSS, and React reports a hydration mismatch on the <style>.

Design tokens you register in @/studio/register-components are in this CSS too, from the first request after the server starts. To leave out the design-token variables, pass { includeDesignTokens: false } as the second argument.

Without the style, the page arrives unstyled, and the Design panel styles appear only after hydration.

export default async function Page(props: Props) {
const specOptions = await loadTemplate(props);
if (!specOptions) notFound();

return (
<>
<style dangerouslySetInnerHTML={{ __html: getSSRStyles(specOptions.spec) }} />
<TemplateRenderer specOptions={specOptions} />
</>
);
}

Set the title and meta tags with generateMetadata​

Export generateMetadata from page.tsx, and build its result from getCompositionMetadata(specOptions, { baseUrl: "http://localhost:3000" }). It returns title, description, ogImage and canonical. canonical is set only when you pass baseUrl.

When the template has no SEO title, title is the template's name. When no template matches, return {}, and let the page call notFound().

For a connected template like Blog post, canonical is the URL pattern, http://localhost:3000/blog/{{entry.slug}}, not the post's address. See SEO and metadata for the fix.

This is the whole page.tsx:

// app/[[...slug]]/page.tsx
import { cache } from "react";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import {
getCompositionMetadata,
getSSRStyles,
isCompositionNotFound,
} from "@contentstack/studio-react";
import { sdk } from "@/lib/contentstack";
import { TemplateRenderer } from "./template-renderer";

export const dynamic = "force-dynamic";

type Props = {
params: Promise<{ slug?: string[] }>;
searchParams: Promise<Record<string, string>>;
};

const getTemplate = cache(async (url: string, searchQuery: string) => {
try {
return await sdk.fetchCompositionData({ url, searchQuery });
} catch (error) {
if (isCompositionNotFound(error)) return null;
throw error;
}
});

async function loadTemplate({ params, searchParams }: Props) {
const { slug = [] } = await params;
const url = "/" + slug.join("/");
const searchQuery = new URLSearchParams(await searchParams).toString();
return getTemplate(url, searchQuery);
}

export async function generateMetadata(props: Props): Promise<Metadata> {
const specOptions = await loadTemplate(props);
if (!specOptions) return {};

const metadata = getCompositionMetadata(specOptions, { baseUrl: "http://localhost:3000" });
return {
title: metadata.title,
description: metadata.description,
openGraph: { title: metadata.title, images: metadata.ogImage ? [metadata.ogImage] : [] },
alternates: { canonical: metadata.canonical },
};
}

export default async function Page(props: Props) {
const specOptions = await loadTemplate(props);
if (!specOptions) notFound();

return (
<>
<style dangerouslySetInnerHTML={{ __html: getSSRStyles(specOptions.spec) }} />
<TemplateRenderer specOptions={specOptions} />
</>
);
}

Check the server HTML has the template, styles and title​

Build and start the app:

npm run build
npm run start

Then request the post without a browser. The App Router sends the HTML on one line, so pick out the parts to check with grep:

curl -s http://localhost:3000/blog/ai-101 | grep -oE '<title>[^<]*</title>|<meta property="og:title"[^>]*>|<style>.{0,48}|<h1[^>]*>[^<]*'

The HTML has <title>The team blog</title> and an og:title meta tag. A <style> tag starting with the design-token variables comes before AI 101's content.

Terminal output of curl for http://localhost:3000/blog/ai-101, piped to grep, showing &lt;title&gt;The team blog&lt;/title&gt;, the og meta tag, the &lt;style&gt; tag starting with the design-token variables, and the h1 with AI 101.

Then request an address that matches no post, and print only its status code:

curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/blog/not-a-post

It answers 404.

Terminal output of curl printing the status code for http://localhost:3000/blog/not-a-post, which is 404.

To deploy the app, see Go live.

If it doesn't work​

  • The page shows Component Loading Error with Component with type 'blog-hero' is not registered. after it loads. TemplateRenderer doesn't import your registrations. Import @/studio/register-components in it, as in Render the template from a client component. The server HTML looks right, so check the browser console, which logs the same error.

The Blog app at http://localhost:3000/blog/ai-101 in a browser, showing Component Loading Error with the message Component with type &#39;blog-hero&#39; is not registered.

  • The page answers 500, and the server log shows Functions cannot be passed directly to Client Components. A fetch option is a function, such as fetchTemplateEntry, and it was copied into specOptions. Leave function options out of a fetch whose specOptions goes to a client component.

The next start log showing Error: Functions cannot be passed directly to Client Components, with the fetchTemplateEntry function marked in the line under it.

  • The browser console reports a hydration mismatch on a <style> element. getSSRStyles runs in the client component. Move it to page.tsx. See Add the template's styles in the Server Component.
  • The page arrives unstyled, then the styles appear. The <style> element from getSSRStyles is missing.
  • Every unknown URL answers 500. The fetch isn't in a try, or the catch doesn't call isCompositionNotFound. See Fetch the template in a Server Component.
  • It throws Required prop `config` not passed to the Composable Component. You passed <StudioComponent /> something other than the fetched specOptions.

Report a problem with this page