• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Advanced Usage

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.

This guide builds the same Paragraph CMS integration used in the working paragraphcms/astro-advanced example: a localized /blog section, translated /{locale}/blog routes, simple React components for the blog index and post page, and generated sitemap.xml, robots.txt, llms.txt, and RSS output.

Want a ready-made project instead? Use the paragraphcms/astro-advanced, which already includes the same localized routes and generated SEO documents shown below.

Install the packages

Add the Paragraph CMS client, React renderer, and SEO package to your Astro app. If your project is not already using React components, install the Astro React integration too:

npm install @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo react react-dom
npm install -D @astrojs/react
pnpm add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo react react-dom
pnpm add -D @astrojs/react
yarn add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo react react-dom
yarn add -D @astrojs/react
bun add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo react react-dom
bun add -d @astrojs/react

Register the React integration in astro.config.mjs if you have not already done so:

astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";

export default defineConfig({
  integrations: [react()],
});

The working astro-advanced project also sets site in Astro config and enables Tailwind, but those parts are independent from the Paragraph CMS integration covered in this guide.

Set environment variables

Create a .env file in the project root:

.env
PARAGRAPH_API_KEY=your_api_key

Create paragraph.config.ts

Create a shared client, a shared site object, and a shared seo instance.

Before continuing, create a collection with the slug blog in app.paragraphcms.com. This example uses localizedContentRoute("blog") and route: "blog", so the collection name in Paragraph CMS must match.

One detail here is easy to misread: routes is not your Astro file tree. It is the route map used by @paragraphcms/seo when it generates canonical URLs, sitemap entries, RSS links, and llms.txt links.

  • 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 blog content route. It tells the library that the blog collection lives at /blog and /${locale}/blog, and that post URLs should be built by appending the page slug.

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.

paragraph.config.ts
import { Client } from "@paragraphcms/client";
import { SEO, localizedContentRoute, localizedRoute } from "@paragraphcms/seo";

const apiKey = import.meta.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 = {
  url: "https://example.com",
  name: "Astro Starter",
};

export const seo = new SEO({
  client,
  site,
  routes: {
    home: localizedRoute("blog"),
    blog: localizedContentRoute("blog"),
  },
});

Create a layout and blog components

Create the same small building blocks used by the working starter. The extra detail compared with the quickstart is the locale-aware URL prefix, so the same components work for /blog and /{locale}/blog.

If you want to use the @/ imports shown below, add "paths": { "@/*": ["./src/*"] } to tsconfig.json. The starter already includes that alias.

src/layouts/main.astro
---
import "@/styles/global.css";

const { locale = "en" } = Astro.props;
---

<html lang={locale}>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>Astro App</title>
  </head>
  <body>
    <slot />
  </body>
</html>
src/components/blog/blog.tsx
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}>
            <a href={`${prefix}/blog/${post.slug}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </main>
  );
}
src/components/blog/post.tsx
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>
        <a href={`${prefix}/blog`}>Back</a>
      </p>
      <h1>{page.title}</h1>
      <ParagraphContent content={page.content} />
    </main>
  );
}

Build /blog

The unprefixed /blog route always renders the default locale. Fetch that locale first, then query pages for that language:

src/pages/blog/index.astro
---
import Layout from "@/layouts/main.astro";
import { Blog } from "../../components/blog/blog";
import { client } from "../../../paragraph.config";

const { data: defaultLocale, error: defaultLocaleError } =
  await client.locales.getDefaultLocale();

if (defaultLocaleError) {
  throw defaultLocaleError;
}

const { data, error } = await client.pages.list({
  language: defaultLocale,
  requiredSlug: true,
});

if (error) {
  throw error;
}
---

<Layout locale={defaultLocale}>
  <Blog defaultLocale={defaultLocale} locale={defaultLocale} posts={data} />
</Layout>

The working starter keeps this query minimal. If your project contains other collections too, add collection: "blog" to the same client.pages.list() call so the index stays limited to blog posts.

Build /blog/[slug]

Generate static paths from the default locale and resolve each page by slug. The extra page.language !== defaultLocale guard keeps translated content out of the unprefixed route.

src/pages/blog/[slug].astro
---
import Layout from "@/layouts/main.astro";
import { Post } from "../../components/blog/post";
import { client } from "../../../paragraph.config";

export async function getStaticPaths() {
  const { data: defaultLocale, error: defaultLocaleError } =
    await client.locales.getDefaultLocale();

  if (defaultLocaleError) {
    throw defaultLocaleError;
  }

  const { data, error } = await client.pages.list({
    language: defaultLocale,
    requiredSlug: true,
  });

  if (error) {
    throw error;
  }

  return data.map((page) => ({
    params: {
      slug: page.slug,
    },
  }));
}

const { data: defaultLocale, error: defaultLocaleError } =
  await client.locales.getDefaultLocale();

if (defaultLocaleError) {
  throw defaultLocaleError;
}

const { data: page, error } = await client.page.getBySlug(Astro.params.slug!);

if (error) {
  throw error;
}

if (page.language !== defaultLocale) {
  Astro.response.status = 404;
}
---

{
  Astro.response.status === 404 ? (
    "Not Found"
  ) : (
    <Layout locale={defaultLocale}>
      <Post defaultLocale={defaultLocale} locale={defaultLocale} page={page} />
    </Layout>
  )
}

Add localized /{locale}/blog routes

Generate only non-default locales under src/pages/[locale]. When someone hits /{defaultLocale}/blog, redirect back to the canonical unprefixed URL instead of rendering duplicate content.

src/pages/[locale]/blog/index.astro
---
import Layout from "@/layouts/main.astro";
import { Blog } from "../../../components/blog/blog";
import { client } from "../../../../paragraph.config";

export async function getStaticPaths() {
  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) => ({ params: { locale: locale.code } }));
}

const { data: defaultLocale, error: defaultLocaleError } =
  await client.locales.getDefaultLocale();

if (defaultLocaleError) {
  throw defaultLocaleError;
}

const locale = Astro.params.locale!;
const redirect = locale === defaultLocale;

const { data, error } = redirect
  ? { data: [], error: null }
  : await client.pages.list({
      language: locale,
      requiredSlug: true,
    });

if (error) {
  throw error;
}
---

{
  redirect ? (
    Astro.redirect("/blog", 301)
  ) : (
    <Layout locale={locale}>
      <Blog defaultLocale={defaultLocale} locale={locale} posts={data} />
    </Layout>
  )
}
src/pages/[locale]/blog/[slug].astro
---
import Layout from "@/layouts/main.astro";
import { Post } from "../../../components/blog/post";
import { client } from "../../../../paragraph.config";

export async function getStaticPaths() {
  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, error } = await client.pages.list({
      language: locale.code,
      requiredSlug: true,
    });

    if (error) {
      throw error;
    }

    params.push(
      ...data.map((page) => ({ locale: locale.code, slug: page.slug })),
    );
  }

  return params.map((params) => ({ params }));
}

const { data: defaultLocale, error: defaultLocaleError } =
  await client.locales.getDefaultLocale();

if (defaultLocaleError) {
  throw defaultLocaleError;
}

const locale = Astro.params.locale!;
const redirect = locale === defaultLocale;

const { data: page, error } = redirect
  ? { data: null, error: null }
  : await client.page.getBySlug(Astro.params.slug!);

if (error) {
  throw error;
}

if (page && page.language !== locale) {
  Astro.response.status = 404;
}
---

{
  redirect ? (
    Astro.redirect(`/blog/${Astro.params.slug!}`, 301)
  ) : Astro.response.status === 404 ? (
    "Not Found"
  ) : (
    <Layout locale={locale}>
      <Post defaultLocale={defaultLocale} locale={locale} page={page!} />
    </Layout>
  )
}

The translated post route follows the same pattern as the default locale: resolve by slug, then use page.language to decide whether the route should render or return 404.

Redirect / to /blog

The working starter keeps /blog as the public entry route, so the root path redirects there:

src/pages/index.astro
---
Astro.response.status = 302;
Astro.response.headers.set("Location", "/blog");
---

That lines up with home: localizedRoute("blog") in paragraph.config.ts.

Add robots.txt, sitemap.xml, and llms.txt

Once seo is configured, each generated document is just a small endpoint:

src/pages/robots.txt.ts
import { seo } from "../../paragraph.config";

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

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

src/pages/blog/rss.xml.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:

src/pages/[locale]/blog/rss.xml.ts
import { client, seo } from "../../../../paragraph.config";

export async function getStaticPaths() {
  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) => ({ params: { locale: locale.code } }));
}

export async function GET({ params }: { params: { locale: string } }) {
  const { data: defaultLocale, error } = await client.locales.getDefaultLocale();

  if (error) {
    throw error;
  }

  if (params.locale === defaultLocale) {
    return new Response("Not Found", { status: 404 });
  }

  return new Response(
    await seo.rssXml({
      locale: params.locale,
      route: "blog",
    }),
    {
      headers: {
        "content-type": "application/rss+xml; charset=utf-8",
      },
    },
  );
}

As with the page routes, the localized RSS feed exists only for non-default locales.

Verify the finished setup

Before building, make sure your Paragraph project has:

  • a collection with the slug blog
  • at least one published page with a slug
  • a default locale, plus any translated locales you want to publish

Then your Astro app should expose:

  • / redirecting to /blog
  • /blog
  • /blog/[slug]
  • /{locale}/blog
  • /{locale}/blog/[slug]
  • /robots.txt
  • /sitemap.xml
  • /llms.txt
  • /blog/rss.xml
  • /{locale}/blog/rss.xml

That matches the route and generated-document shape of the working astro-advanced starter.

Next.js

Build a localized Next.js blog with Paragraph CMS from scratch, including sitemap.xml, robots.txt, llms.txt, and RSS output.

React Router

Build a localized React Router blog with Paragraph CMS from scratch, including prerendered routes, sitemap.xml, robots.txt, llms.txt, and RSS output.

On this page

Install the packagesSet environment variablesCreate paragraph.config.tsCreate a layout and blog componentsBuild /blogBuild /blog/[slug]Add localized /{locale}/blog routesRedirect / to /blogAdd robots.txt, sitemap.xml, and llms.txtAdd RSS for the default locale and translated localesVerify the finished setup