Next.js
Build a localized, server-rendered Next.js blog with Paragraph CMS and manually generated public documents.
This guide extends the Next.js quickstart with a
localized /blog section and server routes for robots.txt, sitemap.xml,
and llms.txt. Server-side rendering (SSR) is the recommended delivery model
for Paragraph CMS.
Install the packages
npm install @paragraphcms/client @paragraphcms/parser-reactThe quickstart contains the component setup. This guide adds localized routes and public documents without another dependency.
Create the server-only client and URL helpers
Create a blog collection in Paragraph CMS, then add this file. Keep
PARAGRAPH_API_KEY in .env.local and never import this module into a
client component.
import { Client } from "@paragraphcms/client";
const apiKey = process.env.PARAGRAPH_API_KEY;
if (!apiKey) {
throw new Error("PARAGRAPH_API_KEY environment variable is not set");
}
export const client = new Client({ apiKey });
export const site = {
name: "Example blog",
url: "https://example.com",
};
export function blogPath(
locale: string,
defaultLocale: string,
slug?: string,
) {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
const suffix = slug ? `/${slug}` : "";
return `${prefix}/blog${suffix}`;
}
export function publicUrl(pathname: string) {
return new URL(pathname, site.url).toString();
}Server-render the default-locale blog
The unprefixed routes always use the workspace default locale. Explicitly opt into request-time rendering for the public content routes.
import { Blog } from "@/components/blog/blog";
import { client } from "@/paragraph.config";
export const dynamic = "force-dynamic";
export default async function Page() {
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: posts, error } = await client.pages.list({
collection: "blog",
language: defaultLocale,
requiredSlug: true,
});
if (error) {
throw error;
}
return <Blog defaultLocale={defaultLocale} locale={defaultLocale} posts={posts} />;
}import { notFound } from "next/navigation";
import { Post } from "@/components/blog/post";
import { client } from "@/paragraph.config";
export const dynamic = "force-dynamic";
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: page, error } = await client.page.getBySlug(slug);
if (error) {
throw error;
}
if (page.language !== defaultLocale) {
notFound();
}
return <Post defaultLocale={defaultLocale} locale={defaultLocale} page={page} />;
}Add localized SSR routes
Use app/[locale] only for non-default locales. Each request verifies the
locale before rendering, so the canonical default URLs remain unprefixed.
import { notFound } from "next/navigation";
import { Blog } from "@/components/blog/blog";
import { client } from "@/paragraph.config";
export const dynamic = "force-dynamic";
export default async function Page({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
if (locale === defaultLocale) {
notFound();
}
const { data: posts, error } = await client.pages.list({
collection: "blog",
language: locale,
requiredSlug: true,
});
if (error) {
throw error;
}
return <Blog defaultLocale={defaultLocale} locale={locale} posts={posts} />;
}import { notFound } from "next/navigation";
import { Post } from "@/components/blog/post";
import { client } from "@/paragraph.config";
export const dynamic = "force-dynamic";
export default async function Page({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
if (locale === defaultLocale) {
notFound();
}
const { data: page, error } = await client.page.getBySlug(slug);
if (error) {
throw error;
}
if (page.language !== locale) {
notFound();
}
return <Post defaultLocale={defaultLocale} locale={locale} page={page} />;
}Build public documents without a library
Create a server-only helper. It builds URLs sequentially for every locale and its blog posts, which keeps CMS traffic explicit and easy to trace.
import { blogPath, client, publicUrl, site } from "@/paragraph.config";
function escapeXml(value: string) {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export async function getPublicUrls() {
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: locales, error: localesError } = await client.locales.list();
if (localesError) {
throw localesError;
}
const urls = [publicUrl("/"), publicUrl("/blog")];
for (const locale of locales) {
const { data: posts, error } = await client.pages.list({
collection: "blog",
language: locale.code,
requiredSlug: true,
});
if (error) {
throw error;
}
if (locale.code !== defaultLocale) {
urls.push(publicUrl(blogPath(locale.code, defaultLocale)));
}
for (const post of posts) {
urls.push(publicUrl(blogPath(locale.code, defaultLocale, post.slug)));
}
}
return urls;
}
export function robotsTxt() {
return `User-agent: *\nAllow: /\nSitemap: ${publicUrl("/sitemap.xml")}\n`;
}
export function sitemapXml(urls: string[]) {
const entries = urls
.map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`)
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>`;
}
export function llmsTxt(urls: string[]) {
return `# ${site.name}\n\n## Pages\n${urls.map((url) => `- ${url}`).join("\n")}\n`;
}Add robots.txt, sitemap.xml, and llms.txt
Map the helper to ordinary Next.js Route Handlers:
import { robotsTxt } from "@/lib/public-documents";
export async function GET() {
return new Response(robotsTxt(), {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}import { getPublicUrls, sitemapXml } from "@/lib/public-documents";
export async function GET() {
const urls = await getPublicUrls();
return new Response(sitemapXml(urls), {
headers: { "content-type": "application/xml; charset=utf-8" },
});
}import { getPublicUrls, llmsTxt } from "@/lib/public-documents";
export async function GET() {
const urls = await getPublicUrls();
return new Response(llmsTxt(urls), {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}Deploy the SSR application
Apply cache and invalidation rules to public routes at the deployment layer. Do not cache preview, draft, authenticated, or webhook responses.