Nuxt
Build a localized Nuxt blog with Paragraph CMS from scratch, including prerendered routes, sitemap.xml, robots.txt, llms.txt, and RSS output.
This guide builds a Nuxt blog from scratch with Paragraph CMS, using
build-time prerendering for known blog routes, locale-aware /blog URLs, and
generated sitemap.xml, robots.txt, llms.txt, and RSS output. The code
below follows the same integration pattern as the full
paragraphcms/nuxt-advanced
example and matches the current starter: / redirects to /blog, the
default locale stays unprefixed, translated routes are handled by
@nuxtjs/i18n, and Paragraph content is rendered with ParagraphContent.
Want a ready-made project instead? Use the
paragraphcms/nuxt-advanced,
which already includes localized blog routes and generated sitemap.xml,
robots.txt, llms.txt, and RSS.
The snippets below use the Nuxt 4 layout, so route files live in app/pages.
If your app uses the classic structure instead, place the same route files in
pages/ and keep the Nitro handlers in server/api, server/routes, and
server/utils.
Install the packages
Add the Paragraph CMS client, Vue renderer, SEO package, and Nuxt i18n module to your Nuxt app:
npm install @nuxtjs/i18n @paragraphcms/client @paragraphcms/parser-vue @paragraphcms/seopnpm add @nuxtjs/i18n @paragraphcms/client @paragraphcms/parser-vue @paragraphcms/seoyarn add @nuxtjs/i18n @paragraphcms/client @paragraphcms/parser-vue @paragraphcms/seobun add @nuxtjs/i18n @paragraphcms/client @paragraphcms/parser-vue @paragraphcms/seoSet environment variables
Create a .env file in the project root:
NUXT_PARAGRAPH_API_KEY=your_api_keyCreate paragraph.config.ts
Before you wire up the client, open
https://app.paragraphcms.com, create a
collection named blog, and keep your blog posts there. This keeps the
advanced starter naming consistent with the optional
collection: "blog" examples below, and this guide also uses blog as
the public section name for routes, feeds, and generated SEO documents.
Create a shared client, a shared site object, and a shared seo
instance.
One detail here is easy to misread: routes is not your Nuxt file tree.
It is the 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 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 actual blog content route. It tells the library 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 lived at /, you would use home: localizedRoute()
instead.
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 { localizedContentRoute, localizedRoute, SEO } from "@paragraphcms/seo";
const env = (
globalThis as typeof globalThis & {
process?: { env?: Record<string, string | undefined> };
}
).process?.env;
const apiKey = env?.NUXT_PARAGRAPH_API_KEY?.trim() || "missing-api-key";
export const client = new Client({ apiKey });
export const site = {
description: "Latest posts from the blog.",
name: "Blog",
url: "https://example.com",
};
export const seo = new SEO({
client,
site,
routes: {
home: localizedRoute("blog"),
blog: localizedContentRoute("blog"),
},
});Configure nuxt.config.ts
Configure Nuxt to:
- load locales from Paragraph CMS when the API key is available;
- keep the default locale unprefixed with
prefix_except_default; - redirect
/to/blog; and - prerender the blog index, article pages, feeds, and generated SEO documents from the same CMS route inventory.
import { client } from "./paragraph.config";
const env = (
globalThis as typeof globalThis & {
process?: { env?: Record<string, string | undefined> };
}
).process?.env;
const apiKey = env?.NUXT_PARAGRAPH_API_KEY?.trim();
let locales = ["en"];
let defaultLocale = locales[0];
if (apiKey) {
const [defaultLocaleResult, localesResult] = await Promise.all([
client.locales.getDefaultLocale(),
client.locales.list(),
]);
if (defaultLocaleResult.error) {
throw defaultLocaleResult.error;
}
if (localesResult.error) {
throw localesResult.error;
}
defaultLocale = defaultLocaleResult.data;
locales = Array.from(
new Set([defaultLocale, ...localesResult.data.map(({ code }) => code)]),
);
}
export default defineNuxtConfig({
modules: ["@nuxtjs/i18n"],
runtimeConfig: {
paragraphApiKey: env?.NUXT_PARAGRAPH_API_KEY,
},
i18n: {
defaultLocale,
detectBrowserLanguage: false,
locales,
rootRedirect: "blog",
strategy: "prefix_except_default",
},
hooks: {
async "prerender:routes"(ctx) {
if (!apiKey) {
return;
}
ctx.routes.add("/llms.txt");
ctx.routes.add("/robots.txt");
ctx.routes.add("/sitemap.xml");
for (const locale of locales) {
const { data: pages, error } = await client.pages.list({
language: locale,
requiredSlug: true,
});
if (error) {
throw error;
}
const prefix = locale === defaultLocale ? "" : `/${locale}`;
ctx.routes.add(`${prefix}/blog`);
ctx.routes.add(`${prefix}/blog/rss.xml`);
for (const page of pages) {
ctx.routes.add(`${prefix}/blog/${page.slug}`);
}
}
},
},
nitro: {
prerender: {
crawlLinks: false,
},
},
});Unlike page routes, Nitro server routes are not localized automatically by
@nuxtjs/i18n, so this guide adds explicit localized handlers under
server/api/[locale] and server/routes/[locale] in the next steps.
Create example components
As in the quickstart, create the two rendering components first and reuse
them from the page files in the next steps. app/pages/blog/index.vue
renders Blog.vue, and app/pages/blog/[slug].vue renders Post.vue.
<script setup lang="ts">
type BlogPost = {
id: string;
slug: string;
title: string;
};
const localePath = useLocalePath();
defineProps<{
posts: BlogPost[];
}>();
</script>
<template>
<main>
<h1>Blog</h1>
<ul>
<li v-for="post in posts" :key="post.id">
<NuxtLink :to="localePath(`/blog/${post.slug}`)">{{ post.title }}</NuxtLink>
</li>
</ul>
</main>
</template><script setup lang="ts">
import type { Page } from "@paragraphcms/client";
import { ParagraphContent } from "@paragraphcms/parser-vue";
defineProps<{
page: Page;
}>();
</script>
<template>
<main>
<h1>{{ page.title }}</h1>
<ParagraphContent :content="page.content" />
</main>
</template>Create the server Paragraph helper
Keep the API key private by reading it from Nuxt runtime config inside a
small server utility. This matches the current starter, which shares the
client from paragraph.config.ts but still fails loudly if the runtime key
is missing.
import { client } from "../../paragraph.config";
export function getParagraphClient() {
const { paragraphApiKey } = useRuntimeConfig();
const apiKey = paragraphApiKey?.trim();
if (!apiKey) {
throw new Error("NUXT_PARAGRAPH_API_KEY environment variable is not set");
}
return client;
}Build /blog
Create one Nitro handler for the blog listing, then re-export it under the
localized API path. The page component always fetches from
/api/${locale}/blog, which keeps the data flow identical for the default
locale and translated locales.
import { defineEventHandler } from "h3";
import { getParagraphClient } from "../utils/paragraph";
export default defineEventHandler(async (event) => {
const client = getParagraphClient();
const locale = event.context.params?.locale;
const { data: posts, error } = await client.pages.list({
...(locale ? { language: locale } : {}),
requiredSlug: true,
});
if (error) {
throw error;
}
return posts;
});export { default } from "../blog.get";<script setup lang="ts">
import type { PageSummaryWithSlug } from "@paragraphcms/client";
import Blog from "../../components/blog/Blog.vue";
const { locale } = useI18n();
const { data: posts, error } = await useAsyncData<PageSummaryWithSlug[]>(
() => `blog-posts:${locale.value}`,
() => $fetch(`/api/${locale.value}/blog`),
);
if (error.value) {
throw error.value;
}
</script>
<template>
<Blog :posts="posts ?? []" />
</template>Because requiredSlug: true is set, the API response is typed as
PageSummaryWithSlug[], so post.slug is safe to use directly.
By default, client.pages.list() returns published pages only. If you want
to include unpublished pages too or limit the query to the blog
collection, pass additional parameters under the same call inside
server/api/blog.get.ts:
const client = getParagraphClient();
client.pages.list({
requiredSlug: true,
published: false,
collection: "blog",
});Build /blog/[slug]
Create a second Nitro handler that resolves the requested page by slug
inside the requested locale, then fetches the full record by page ID. This
keeps localized article routes aligned with the language-specific page list.
import { createError, defineEventHandler, getRouterParam } from "h3";
import { getParagraphClient } from "../../utils/paragraph";
export default defineEventHandler(async (event) => {
const locale = event.context.params?.locale;
const slug = getRouterParam(event, "slug")?.trim();
if (!slug) {
throw createError({
statusCode: 404,
statusMessage: "Post not found",
});
}
const client = getParagraphClient();
const { data: pages, error } = await client.pages.list({
...(locale ? { language: locale } : {}),
requiredSlug: true,
slug,
});
if (error) {
throw error;
}
const page = pages[0];
if (!page) {
throw createError({
statusCode: 404,
statusMessage: "Post not found",
});
}
const { data: fullPage, error: fullPageError } = await client.page.get(page.id);
if (fullPageError) {
throw fullPageError;
}
return fullPage;
});export { default } from "../../blog/[slug].get";<script setup lang="ts">
import type { Page } from "@paragraphcms/client";
import Post from "../../components/blog/Post.vue";
const { locale } = useI18n();
const route = useRoute();
const slug = String(route.params.slug ?? "");
const { data: page, error } = await useAsyncData<Page>(
() => `blog-post:${locale.value}:${slug}`,
() => $fetch(`/api/${locale.value}/blog/${encodeURIComponent(slug)}`),
);
if (error.value) {
throw error.value;
}
</script>
<template>
<Post v-if="page" :page="page" />
</template>This keeps /blog reserved for the default locale while translated pages
resolve under /{locale}/blog/[slug] through Nuxt i18n.
Add robots.txt, sitemap.xml, and llms.txt
Once seo is configured, each generated document is just a small Nitro
route that returns the string from @paragraphcms/seo.
import { seo } from "../../paragraph.config";
export default defineEventHandler(async () =>
new Response(await seo.robotsTxt(), {
headers: {
"content-type": "text/plain; charset=utf-8",
},
}),
);import { seo } from "../../paragraph.config";
export default defineEventHandler(async () =>
new Response(await seo.sitemapXml(), {
headers: {
"content-type": "application/xml; charset=utf-8",
},
}),
);import { seo } from "../../paragraph.config";
export default defineEventHandler(async () =>
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 default defineEventHandler(async () =>
new Response(await seo.rssXml({ route: "blog" }), {
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},
}),
);Then add the localized RSS route:
import { createError, getRouterParam } from "h3";
import { seo } from "../../../../paragraph.config";
export default defineEventHandler(async (event) => {
const locale = getRouterParam(event, "locale")?.trim();
if (!locale) {
throw createError({
statusCode: 404,
statusMessage: "Not Found",
});
}
return new Response(
await seo.rssXml({
locale,
route: "blog",
}),
{
headers: {
"content-type": "application/rss+xml; charset=utf-8",
},
},
);
});Verify the finished setup
After these changes, your app 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
That gives you the same route and generated-document shape as the current
nuxt-advanced example while keeping the guide self-contained from the
initial install onward.