• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Astro

Build a localized, server-rendered Astro blog with Paragraph CMS and manually generated public documents.

This guide extends the Astro quickstart with localized routes and manual robots.txt, sitemap.xml, and llms.txt endpoints. Server-side rendering (SSR) is the recommended delivery model for Paragraph CMS.

Configure Astro for SSR

Install the Paragraph packages, React integration, and a server adapter. This example uses the Node adapter; choose the adapter that matches the platform where you deploy.

npm install @paragraphcms/client @paragraphcms/parser-react react react-dom
npm install -D @astrojs/react @astrojs/node
astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import react from "@astrojs/react";

export default defineConfig({
  adapter: node({ mode: "standalone" }),
  integrations: [react()],
  output: "server",
});

Create the shared server module

Create a blog collection in Paragraph CMS, add PARAGRAPH_API_KEY to .env, then create the server-only module below. It also contains the URL rules used by pages and public documents.

paragraph.config.ts
import { Client } from "@paragraphcms/client";

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 = {
  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 pages

The default locale uses unprefixed URLs. Fetch the locale and content in the page frontmatter, which runs on the server for every request.

src/pages/blog/index.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: posts, error } = await client.pages.list({
  collection: "blog",
  language: defaultLocale,
  requiredSlug: true,
});

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

<Blog defaultLocale={defaultLocale} locale={defaultLocale} posts={posts} />
src/pages/blog/[slug].astro
---
import { Post } from "../../components/blog/post";
import { client } from "../../../paragraph.config";

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" : (
  <Post defaultLocale={defaultLocale} locale={defaultLocale} page={page} />
)}

Add localized SSR pages

Add the same two routes below src/pages/[locale]/blog. The default locale redirects to its canonical unprefixed URL; translated content is verified against the requested locale.

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

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

if (defaultLocaleError) {
  throw defaultLocaleError;
}

if (locale === defaultLocale) {
  return Astro.redirect("/blog", 301);
}

const { data: posts, error } = await client.pages.list({
  collection: "blog",
  language: locale,
  requiredSlug: true,
});

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

<Blog defaultLocale={defaultLocale} locale={locale} posts={posts} />
src/pages/[locale]/blog/[slug].astro
---
import { Post } from "../../../components/blog/post";
import { client } from "../../../../paragraph.config";

const locale = Astro.params.locale!;
const slug = Astro.params.slug!;
const { data: defaultLocale, error: defaultLocaleError } =
  await client.locales.getDefaultLocale();

if (defaultLocaleError) {
  throw defaultLocaleError;
}

if (locale === defaultLocale) {
  return Astro.redirect(`/blog/${slug}`, 301);
}

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

if (error) {
  throw error;
}

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

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

Build public documents manually

Create a helper that builds public URLs from the same locale and blog queries as the pages. Calls are intentionally sequential, keeping the server-side request pattern simple.

src/lib/public-documents.ts
import { blogPath, client, publicUrl, site } from "../../paragraph.config";

function escapeXml(value: string) {
  return value
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&apos;");
}

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 the public document endpoints

src/pages/robots.txt.ts
import { robotsTxt } from "../lib/public-documents";

export function GET() {
  return new Response(robotsTxt(), {
    headers: { "content-type": "text/plain; charset=utf-8" },
  });
}
src/pages/sitemap.xml.ts
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" },
  });
}
src/pages/llms.txt.ts
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 with a public-route cache

Cache public published pages and document endpoints in the deployment layer. Keep previews, drafts, authenticated responses, and webhooks out of the shared cache.

Next.js

Build a localized, server-rendered Next.js blog with Paragraph CMS and manually generated public documents.

React Router

Build a localized, server-rendered React Router blog with Paragraph CMS and manually generated public documents.

On this page

Configure Astro for SSRCreate the shared server moduleServer-render the default-locale pagesAdd localized SSR pagesBuild public documents manuallyAdd the public document endpointsDeploy with a public-route cache