• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

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/i18n

Keep the API key private in .env and configure Nuxt to render on the server:

.env
NUXT_PARAGRAPH_API_KEY=your_api_key
nuxt.config.ts
export 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.

server/utils/paragraph.ts
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;
}
server/utils/site.ts
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.

server/api/blog.get.ts
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 };
});
server/api/blog/[slug].get.ts
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.

app/pages/blog/index.vue
<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>
app/pages/blog/[slug].vue
<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

server/utils/public-documents.ts
import { getParagraphClient } from "./paragraph";
import { blogPath, publicUrl, site } from "./site";

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

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

server/routes/robots.txt.ts
import { robotsTxt } from "../utils/public-documents";

export default defineEventHandler((event) => {
  setResponseHeader(event, "content-type", "text/plain; charset=utf-8");
  return robotsTxt();
});
server/routes/sitemap.xml.ts
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);
});
server/routes/llms.txt.ts
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.

React Router

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

SvelteKit

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

On this page

Install the packages and enable SSRCreate server-only CMS and URL helpersAdd server API routes for the blogRender the pages with SSRBuild public documents manuallyAdd robots.txt, sitemap.xml, and llms.txtDeploy the SSR application