• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Advanced Usage

Manual

Build a production-ready Paragraph CMS integration manually with custom routing, localization, generated SEO documents, and safe rendering strategy.

This guide shows how to build a production-ready Paragraph CMS integration without relying on a framework-specific advanced starter. It follows the same architecture used across the advanced examples: a shared Paragraph CMS client, a shared SEO configuration, locale-aware /blog URLs, generated sitemap.xml, robots.txt, llms.txt, and RSS output, plus a rendering strategy that keeps content delivery on the server.

Use this page when you want the same integration shape as the advanced framework guides, but you are working in a custom stack, a different runtime, or an internal architecture where file-level routing examples would be too specific.

Do not fetch published content on the client

Paragraph CMS content should be fetched on the server only. Do not load published article content from the browser, client components, or client-side data hooks. Prefer SSG, prerendering, or SSR behind a cache. If a route is rendered on demand, cache the server response aggressively instead of re-fetching content from each client visit. Otherwise you create unnecessary API traffic, weaken SEO, and can run into rate limits.

Install the packages

Add the Paragraph CMS client, the renderer for your UI layer, and the SEO package. The commands below use React; in Vue or Svelte projects, replace @paragraphcms/parser-react with @paragraphcms/parser-vue or @paragraphcms/parser-svelte.

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

The core @paragraphcms/client works in any JavaScript runtime. Use the parser package only on the server-rendered side of your app.

Set environment variables

Store the API key in a server-only environment variable:

.env
PARAGRAPH_API_KEY=your_api_key

Do not expose this key to browser bundles. If your platform separates public and private env vars, keep this one in the private server-only set.

Create paragraph.config.ts

Before wiring the client, create a collection named blog in your Paragraph CMS workspace at https://app.paragraphcms.com and keep your public posts there. This guide uses blog as the public section name for routes, feeds, and generated SEO documents.

Put the CMS client, public site metadata, and SEO route map in one shared file. That file becomes the single source of truth for content access and generated URLs.

One detail here is easy to misread: routes is not your framework route tree. It is the public 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 layer 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 content section. It tells the SEO layer 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 lives at /, use home: localizedRoute() instead.

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 site = {
  url: "https://example.com",
  name: "My Blog",
  description: "Latest posts from the blog.",
};

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

Set site.url to your production domain. site.defaultLocale is optional. If you omit it, new SEO() uses the default locale configured in Paragraph CMS.

If your public blog section maps to a specific collection only, extend the route definition with params: { collection: "blog" }.

Decide the rendering and cache strategy first

Treat rendering strategy as an application-level decision, not a route implementation detail.

For published content, prefer one of these two shapes:

  • SSG or build-time prerendering for stable public routes such as /blog, /blog/[slug], /{locale}/blog, and /{locale}/blog/[slug].
  • SSR or on-demand rendering behind a real cache, with explicit revalidation or purge logic when content changes.

Avoid these patterns for published content:

  • browser-side fetches after hydration;
  • client components that request article bodies directly from Paragraph CMS;
  • cache: no-store on every request unless the route is intentionally a preview or draft-only path; and
  • permanent stale cache with no invalidation path if your content updates regularly.

A good default is:

  • prerender routes whose slug list is known at build time;
  • use SSR only where route generation cannot happen ahead of time; and
  • put a CDN, edge cache, or framework cache in front of SSR responses so the CMS is not hit for every page view.

Reserve uncached request-time fetching mostly for preview, editorial tools, migrations, and internal automation.

Build shared content functions

Put route-independent content logic in shared server functions. That keeps route generation, page rendering, feed generation, and SEO documents aligned around the same CMS queries.

lib/blog.ts
import type { PageSummaryWithSlug, PageWithSlug } from "@paragraphcms/client";

import { client } from "../paragraph.config";

export async function getDefaultLocale(): Promise<string> {
  const { data, error } = await client.locales.getDefaultLocale();

  if (error) {
    throw error;
  }

  return data;
}

export async function getLocales() {
  const { data, error } = await client.locales.list();

  if (error) {
    throw error;
  }

  return data;
}

export async function getBlogPosts(locale: string): Promise<PageSummaryWithSlug[]> {
  const { data, error } = await client.pages.list({
    collection: "blog",
    language: locale,
    requiredSlug: true,
  });

  if (error) {
    throw error;
  }

  return data;
}

export async function getBlogPost(slug: string): Promise<PageWithSlug> {
  const { data, error } = await client.page.getBySlug(slug);

  if (error) {
    throw error;
  }

  return data;
}

By default, client.pages.list() returns published pages only. If you need unpublished content, use a separate preview flow and make that behavior explicit rather than mixing draft and published delivery paths together.

Build the public route inventory

Generate your route inventory from Paragraph CMS data, not from a manual list. That inventory should drive prerendering, static param generation, cache warmup, and deployment checks.

lib/routes.ts
import { getBlogPosts, getDefaultLocale, getLocales } from "./blog";

export async function getPublicRoutes() {
  const defaultLocale = await getDefaultLocale();
  const locales = await getLocales();
  const routes = new Set<string>([
    "/",
    "/blog",
    "/blog/rss.xml",
    "/robots.txt",
    "/sitemap.xml",
    "/llms.txt",
  ]);

  const defaultPosts = await getBlogPosts(defaultLocale);

  for (const post of defaultPosts) {
    routes.add(`/blog/${post.slug}`);
  }

  for (const locale of locales) {
    if (locale.code === defaultLocale) {
      continue;
    }

    const prefix = `/${locale.code}`;
    const posts = await getBlogPosts(locale.code);

    routes.add(prefix);
    routes.add(`${prefix}/blog`);
    routes.add(`${prefix}/blog/rss.xml`);

    for (const post of posts) {
      routes.add(`${prefix}/blog/${post.slug}`);
    }
  }

  return Array.from(routes);
}

Use this route inventory in whatever build primitive your framework exposes: prerender entries, static params, generated path lists, or cache warmup jobs.

Build /blog and /blog/[slug]

Keep the default locale unprefixed and treat /blog as the public entry point. For article pages, derive known slugs from the CMS and resolve the full page on the server.

server/blog.ts
import { getBlogPost, getBlogPosts, getDefaultLocale } from "../lib/blog";

export async function renderBlogIndex() {
  const defaultLocale = await getDefaultLocale();
  const posts = await getBlogPosts(defaultLocale);

  return {
    defaultLocale,
    locale: defaultLocale,
    posts,
  };
}

export async function renderBlogPost(slug: string) {
  const defaultLocale = await getDefaultLocale();
  const page = await getBlogPost(slug);

  return {
    defaultLocale,
    locale: defaultLocale,
    page,
  };
}

If your framework supports build-time param generation, use getBlogPosts(defaultLocale) to derive the slug list and prerender every known /blog/[slug] route.

Add localized /{locale}/blog routes

For translated routes, keep the default locale unprefixed and only expose prefixed routes for non-default locales. That avoids duplicate content between /blog and /{default-locale}/blog.

A generic server-side pattern looks like this:

server/localized-blog.ts
import { getBlogPost, getBlogPosts, getDefaultLocale } from "../lib/blog";

export async function renderLocalizedBlogIndex(locale: string) {
  const defaultLocale = await getDefaultLocale();

  if (locale === defaultLocale) {
    throw new Error("Default locale must stay on the unprefixed route");
  }

  const posts = await getBlogPosts(locale);

  return {
    defaultLocale,
    locale,
    posts,
  };
}

export async function renderLocalizedBlogPost(locale: string, slug: string) {
  const defaultLocale = await getDefaultLocale();

  if (locale === defaultLocale) {
    throw new Error("Default locale must stay on the unprefixed route");
  }

  const page = await getBlogPost(slug);

  if (page.language !== locale) {
    throw new Error("Not Found");
  }

  return {
    defaultLocale,
    locale,
    page,
  };
}

In a real app, map those guard failures to your framework's 404 or notFound() behavior instead of returning a generic server error.

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

Once seo is configured, each generated document can be a very small route handler that returns the string output from @paragraphcms/seo.

server/seo-documents.ts
import { seo } from "../paragraph.config";

export async function getRobotsTxt() {
  return new Response(await seo.robotsTxt(), {
    headers: {
      "content-type": "text/plain; charset=utf-8",
    },
  });
}

export async function getSitemapXml() {
  return new Response(await seo.sitemapXml(), {
    headers: {
      "content-type": "application/xml; charset=utf-8",
    },
  });
}

export async function getLlmsTxt() {
  return new Response(await seo.llmsTxt(), {
    headers: {
      "content-type": "text/plain; charset=utf-8",
    },
  });
}

Keep these handlers on the server. They should use the same paragraph.config.ts source of truth as the rest of the app.

Add RSS for the default locale and translated locales

Feed generation should follow the same public route rules as the page routes.

server/rss.ts
import { getDefaultLocale } from "../lib/blog";
import { seo } from "../paragraph.config";

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

export async function getLocalizedBlogRss(locale: string) {
  const defaultLocale = await getDefaultLocale();

  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",
      },
    },
  );
}

This keeps /blog/rss.xml reserved for the default locale and exposes translated feeds only under /{locale}/blog/rss.xml.

Verify the finished setup

After these changes, your application 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

The important constraint is the delivery model:

  • fetch content on the server only;
  • prerender or statically generate where possible;
  • if a route must render on demand, put a real cache in front of it and define a revalidation strategy; and
  • do not keep published content on an uncached client-side fetch loop.

That gives you the same architecture as the advanced framework examples while keeping the implementation generic enough for a custom stack.

SvelteKit

Build a localized SvelteKit blog with Paragraph CMS, including sitemap.xml, robots.txt, llms.txt, and RSS output.

Shadcn Registry

Install Paragraph CMS editorial components directly with the shadcn CLI.

On this page

Install the packagesSet environment variablesCreate paragraph.config.tsDecide the rendering and cache strategy firstBuild shared content functionsBuild the public route inventoryBuild /blog and /blog/[slug]Add localized /{locale}/blog routesAdd generated robots.txt, sitemap.xml, and llms.txtAdd RSS for the default locale and translated localesVerify the finished setup