Nuxt
Build a localized, server-rendered Nuxt blog with Paragraph CMS and manually generated public documents.
This guide extends the Nuxt quickstart with localized
blog routes and manual robots.txt, sitemap.xml, and llms.txt endpoints.
Server-side rendering (SSR) is the recommended delivery model for Paragraph
CMS.
Install the packages and enable SSR
npm install @paragraphcms/client @paragraphcms/parser-vue @nuxtjs/i18nKeep the API key private in .env and configure Nuxt to render on the
server:
NUXT_PARAGRAPH_API_KEY=your_api_keyexport default defineNuxtConfig({
modules: ["@nuxtjs/i18n"],
runtimeConfig: {
paragraphApiKey: process.env.NUXT_PARAGRAPH_API_KEY,
},
ssr: true,
});Create server-only CMS and URL helpers
Create a blog collection in Paragraph CMS. The CMS client lives under
server/, so neither the API key nor CMS calls are bundled for the
browser.
import { Client } from "@paragraphcms/client";
let paragraphClient: Client | undefined;
let paragraphClientApiKey: string | undefined;
export function getParagraphClient() {
const { paragraphApiKey } = useRuntimeConfig();
const apiKey = paragraphApiKey?.trim();
if (!apiKey) {
throw new Error("NUXT_PARAGRAPH_API_KEY environment variable is not set");
}
if (!paragraphClient || paragraphClientApiKey !== apiKey) {
paragraphClient = new Client({ apiKey });
paragraphClientApiKey = apiKey;
}
return paragraphClient;
}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();
}Add server API routes for the blog
Nuxt pages call these application routes. The browser only talks to your Nuxt server; Paragraph is queried on the server.
import { getParagraphClient } from "../utils/paragraph";
export default defineEventHandler(async () => {
const client = getParagraphClient();
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;
}
return { defaultLocale, locale: defaultLocale, posts };
});import { createError, getRouterParam } from "h3";
import { getParagraphClient } from "../../utils/paragraph";
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug");
if (!slug) {
throw createError({ statusCode: 404 });
}
const client = getParagraphClient();
const { data: defaultLocale, error: defaultLocaleError } =
await client.locales.getDefaultLocale();
if (defaultLocaleError) {
throw defaultLocaleError;
}
const { data: page, error } = await client.page.getBySlug(slug);
if (error) {
throw error;
}
if (page.language !== defaultLocale) {
throw createError({ statusCode: 404 });
}
return { defaultLocale, locale: defaultLocale, page };
});For server/api/[locale]/blog.get.ts and
server/api/[locale]/blog/[slug].get.ts, read the locale route
parameter, reject the default locale, and use it as language. The post
handler must also reject a page whose page.language differs from it.
Render the pages with SSR
useAsyncData() runs during SSR for the initial document request. On
later navigation it calls your server API route, never Paragraph directly
from the browser.
<script setup lang="ts">
import Blog from "../../components/blog/Blog.vue";
const { data, error } = await useAsyncData(
"blog",
() => $fetch("/api/blog"),
);
if (error.value) {
throw error.value;
}
</script>
<template>
<Blog
v-if="data"
:default-locale="data.defaultLocale"
:locale="data.locale"
:posts="data.posts"
/>
</template><script setup lang="ts">
import Post from "../../components/blog/Post.vue";
const route = useRoute();
const slug = String(route.params.slug ?? "");
const { data, error } = await useAsyncData(
`blog-post:${slug}`,
() => $fetch(`/api/blog/${encodeURIComponent(slug)}`),
);
if (error.value) {
throw error.value;
}
</script>
<template>
<Post
v-if="data"
:default-locale="data.defaultLocale"
:locale="data.locale"
:page="data.page"
/>
</template>Add matching pages under app/pages/[locale]/blog that request
/api/${locale}/blog and /api/${locale}/blog/${slug}.
Build public documents manually
import { getParagraphClient } from "./paragraph";
import { blogPath, publicUrl, site } from "./site";
function escapeXml(value: string) {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
export async function getPublicUrls() {
const client = getParagraphClient();
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 robots.txt, sitemap.xml, and llms.txt
import { robotsTxt } from "../utils/public-documents";
export default defineEventHandler((event) => {
setResponseHeader(event, "content-type", "text/plain; charset=utf-8");
return robotsTxt();
});import { getPublicUrls, sitemapXml } from "../utils/public-documents";
export default defineEventHandler(async (event) => {
const urls = await getPublicUrls();
setResponseHeader(event, "content-type", "application/xml; charset=utf-8");
return sitemapXml(urls);
});import { getPublicUrls, llmsTxt } from "../utils/public-documents";
export default defineEventHandler(async (event) => {
const urls = await getPublicUrls();
setResponseHeader(event, "content-type", "text/plain; charset=utf-8");
return llmsTxt(urls);
});Deploy the SSR application
Public published pages and document routes can be cached at deployment. Preview, draft, authenticated, and webhook routes must bypass shared caching.