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/reactpnpm add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo react react-dom
pnpm add -D @astrojs/reactyarn add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo react react-dom
yarn add -D @astrojs/reactbun add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seo react react-dom
bun add -d @astrojs/reactRegister the React integration in astro.config.mjs if you have not
already done so:
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.
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/blogas the public entry point. It resolves to/blogfor the default locale and/${locale}/blogfor translated locales.blog: localizedContentRoute("blog")defines the blog content route. It tells the library that theblogcollection lives at/blogand/${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.
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.
---
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>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>
);
}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:
---
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.
---
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.
---
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>
)
}---
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:
---
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:
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 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.