Next.js
Build a localized Next.js blog with Paragraph CMS from scratch, including sitemap.xml, robots.txt, llms.txt, and RSS output.
This guide builds a Next.js App Router blog from scratch with Paragraph CMS,
using build-time generation for known blog routes, locale-aware /blog URLs,
and generated sitemap.xml, robots.txt, llms.txt, and RSS output. The
code below follows the same integration pattern as the full
paragraphcms/nextjs-advanced
example and matches the current starter: / redirects to /blog, the default
locale stays unprefixed, translated routes live under app/[locale], and
Paragraph content is rendered with ParagraphContent.
This guide assumes you already have a standard Next.js App Router project
scaffolded with create-next-app, so the required root app/layout.tsx is
already in place.
Want a ready-made project instead? Use the
paragraphcms/nextjs-advanced,
which already includes localized blog routes and generated sitemap.xml,
robots.txt, llms.txt, and RSS.
Install the packages
Add the Paragraph CMS client, React renderer, and SEO package to your Next.js app:
npm install @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seopnpm add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seoyarn add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seobun add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seoSet environment variables
Create a .env.local file in the project root. The current starter also
ships an .env.example file with the same variable name.
PARAGRAPH_API_KEY=your_api_keyCreate paragraph.config.ts
Before you wire up the client, create a collection named blog in your
Paragraph CMS workspace at
https://app.paragraphcms.com and keep
your blog posts there. This guide uses blog as the public section name
for routes, feeds, and generated SEO documents.
One detail here is easy to misread: routes is not your Next.js file tree.
It is the route map used by @paragraphcms/seo when it generates
canonical URLs, sitemap entries, RSS links, and llms.txt links.
In this example:
home: localizedRoute("blog")tells the SEO library to treat/blogas the public entry point. It resolves to/blogfor the default locale and/${locale}/blogfor translated locales.blog: localizedContentRoute("blog")defines the actual blog content route. It tells the library that the blog index lives at/blogand/${locale}/blog, and that post URLs should be built by appending the page slug.
Both entries use "blog" on purpose. The first says "this is the public
home URL", and the second says "this is the blog content section".
If your real homepage lived at /, you would use home: localizedRoute()
instead.
Set site.url to your production site URL so generated sitemap entries and
feeds point to the correct domain.
site.defaultLocale is optional. If you omit that field, new SEO() uses
the default locale configured in Paragraph CMS.
If you later want the SEO layer to filter that collection explicitly too,
extend localizedContentRoute("blog") with
params: { collection: "blog" }.
import { Client } from "@paragraphcms/client";
import { SEO, localizedContentRoute, localizedRoute } from "@paragraphcms/seo";
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 seo = new SEO({
client,
site: {
url: "https://example.com",
name: "Next.js Starter",
},
routes: {
home: localizedRoute("blog"),
blog: localizedContentRoute("blog"),
},
});Create example components
Create the same two small components used in the current starter: a blog index component that keeps links locale-aware, and a post component that renders Paragraph content.
import Link from "next/link";
type BlogPost = {
id: string;
slug: string;
title: string;
};
export function Blog({
defaultLocale,
locale,
posts,
}: {
defaultLocale: string;
locale: string;
posts: BlogPost[];
}) {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`${prefix}/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
</main>
);
}import Link from "next/link";
import type { Page } from "@paragraphcms/client";
import { ParagraphContent } from "@paragraphcms/parser-react";
export function Post({
defaultLocale,
locale,
page,
}: {
defaultLocale: string;
locale: string;
page: Page;
}) {
const prefix = locale === defaultLocale ? "" : `/${locale}`;
return (
<main>
<p>
<Link href={`${prefix}/blog`}>Back to blog</Link>
</p>
<h1>{page.title}</h1>
<ParagraphContent content={page.content} />
</main>
);
}Redirect / to /blog
This starter treats /blog as the public entry point, so the root page is
just a redirect:
import { redirect } from "next/navigation";
export default function Page() {
redirect("/blog");
}Build /blog
The unprefixed /blog route renders the default locale. Setting
requiredSlug: true keeps the result linkable, and
language: defaultLocale pins the unprefixed route to the workspace
default locale.
import { Blog } from "@/components/blog/blog";
import { client } from "@/paragraph.config";
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({
language: defaultLocale,
requiredSlug: true,
});
if (error) {
throw error;
}
return <Blog defaultLocale={defaultLocale} locale={defaultLocale} posts={posts} />;
}Build /blog/[slug]
For article pages, resolve static params from the default locale, then
fetch the full page by slug and render it through the shared Post
component.
import { Post } from "@/components/blog/post";
import { client } from "@/paragraph.config";
export const dynamicParams = false;
export async function generateStaticParams() {
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: posts, error } = await client.pages.list({
language: defaultLocale,
requiredSlug: true,
});
if (error) {
throw error;
}
return posts;
}
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { data: defaultLocale, error } = await client.locales.getDefaultLocale();
if (error) {
throw error;
}
const { data: page, error: pageError } = await client.page.getBySlug(slug);
if (pageError) {
throw pageError;
}
return <Post defaultLocale={defaultLocale} locale={defaultLocale} page={page} />;
}Add localized /[locale]/blog routes
Add a second route tree for non-default locales. The default locale stays
unprefixed, and only translated routes are generated under app/[locale].
For translated article pages, fetch by slug first, then verify that the
resolved page belongs to the requested locale.
import { notFound } from "next/navigation";
import { Blog } from "@/components/blog/blog";
import { client } from "@/paragraph.config";
export const dynamicParams = false;
export async function generateStaticParams() {
const { data: locales, error: localesError } = await client.locales.list();
if (localesError) {
throw localesError;
}
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
return locales
.filter((locale) => locale.code !== defaultLocale)
.map((locale) => ({ locale: locale.code }));
}
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({
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 dynamicParams = false;
export async function generateStaticParams() {
const { data: locales, error: localesError } = await client.locales.list();
if (localesError) {
throw localesError;
}
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const params: { locale: string; slug: string }[] = [];
for (const locale of locales) {
if (locale.code === defaultLocale) {
continue;
}
const { data: posts, error } = await client.pages.list({
language: locale.code,
requiredSlug: true,
});
if (error) {
throw error;
}
params.push(
...posts.map((post) => ({ locale: locale.code, slug: post.slug })),
);
}
return params;
}
export default async function Page({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const { data: defaultLocale, error } = await client.locales.getDefaultLocale();
if (error) {
throw error;
}
if (locale === defaultLocale) {
notFound();
}
const { data: page, error: pageError } = await client.page.getBySlug(slug);
if (pageError) {
throw pageError;
}
if (page.language !== locale) {
notFound();
}
return <Post defaultLocale={defaultLocale} locale={locale} page={page} />;
}This keeps /blog reserved for the default locale and prevents duplicate
content under /{default-locale}/blog.
Add robots.txt, sitemap.xml, and llms.txt
Once seo is configured, each generated document is just a small Route
Handler that returns the string from @paragraphcms/seo.
import { seo } from "@/paragraph.config";
export async function GET() {
return new Response(await seo.robotsTxt(), {
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}import { seo } from "@/paragraph.config";
export async function GET() {
return new Response(await seo.sitemapXml(), {
headers: {
"content-type": "application/xml; charset=utf-8",
},
});
}import { seo } from "@/paragraph.config";
export async function GET() {
return new Response(await seo.llmsTxt(), {
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
}Add RSS for the default locale and translated locales
Add one route for the default locale feed:
import { seo } from "@/paragraph.config";
export async function GET() {
return new Response(
await seo.rssXml({
route: "blog",
}),
{
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},
},
);
}Then add the localized RSS route:
import { client, seo } from "@/paragraph.config";
export async function GET(
_request: Request,
context: { params: Promise<{ locale: string }> },
) {
const { locale } = await context.params;
const { data: defaultLocale, error } = await client.locales.getDefaultLocale();
if (error) {
throw error;
}
if (locale === defaultLocale) {
return new Response("Not Found", { status: 404 });
}
return new Response(
await seo.rssXml({
locale,
route: "blog",
}),
{
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},
},
);
}Verify the finished setup
After these changes, your app should expose or redirect:
/redirects to/blog/blog/blog/[slug]/{locale}/blog/{locale}/blog/[slug]/robots.txt/sitemap.xml/llms.txt/blog/rss.xml/{locale}/blog/rss.xml
That gives you the same route and generated-document shape as the current
nextjs-advanced example while keeping the guide self-contained from the
initial install onward.
Manual
Framework-agnostic SSR, SSG, prerendering, and on-demand rendering patterns for integrating Paragraph CMS manually.
Astro
Build the same localized Astro blog as the working astro-advanced example, including simple React components, locale-aware routes, sitemap.xml, robots.txt, llms.txt, and RSS output.