• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Advanced Usage

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/seo
pnpm add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo
yarn add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo
bun add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo

Set 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.

.env.local
PARAGRAPH_API_KEY=your_api_key

Create 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 /blog as the public entry point. It resolves to /blog for the default locale and /${locale}/blog for translated locales.
  • blog: localizedContentRoute("blog") defines the actual blog content route. It tells the library that the blog index lives at /blog and /${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" }.

paragraph.config.ts
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.

components/blog/blog.tsx
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>
  );
}
components/blog/post.tsx
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:

app/page.tsx
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.

app/blog/page.tsx
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.

app/blog/[slug]/page.tsx
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.

app/[locale]/blog/page.tsx
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} />;
}
app/[locale]/blog/[slug]/page.tsx
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.

app/robots.txt/route.ts
import { seo } from "@/paragraph.config";

export async function GET() {
  return new Response(await seo.robotsTxt(), {
    headers: {
      "content-type": "text/plain; charset=utf-8",
    },
  });
}
app/sitemap.xml/route.ts
import { seo } from "@/paragraph.config";

export async function GET() {
  return new Response(await seo.sitemapXml(), {
    headers: {
      "content-type": "application/xml; charset=utf-8",
    },
  });
}
app/llms.txt/route.ts
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:

app/blog/rss.xml/route.ts
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:

app/[locale]/blog/rss.xml/route.ts
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.

On this page

Install the packagesSet environment variablesCreate paragraph.config.tsCreate example componentsRedirect / to /blogBuild /blogBuild /blog/[slug]Add localized /[locale]/blog routesAdd robots.txt, sitemap.xml, and llms.txtAdd RSS for the default locale and translated localesVerify the finished setup