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/seopnpm add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seoyarn add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seobun add @paragraphcms/client @paragraphcms/parser-react @paragraphcms/seoThe 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:
PARAGRAPH_API_KEY=your_api_keyDo 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/blogas the public entry point. It resolves to/blogfor the default locale and/${locale}/blogfor translated locales.blog: localizedContentRoute("blog")defines the actual content section. It tells the SEO layer that the blog index lives at/blogand/${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.
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-storeon 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.
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.
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.
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:
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.
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.
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.