• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Advanced Usage

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/seo
pnpm add @nuxtjs/i18n @paragraphcms/client @paragraphcms/parser-vue @paragraphcms/seo
yarn add @nuxtjs/i18n @paragraphcms/client @paragraphcms/parser-vue @paragraphcms/seo
bun add @nuxtjs/i18n @paragraphcms/client @paragraphcms/parser-vue @paragraphcms/seo

Set environment variables

Create a .env file in the project root:

.env
NUXT_PARAGRAPH_API_KEY=your_api_key

Create 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 /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 blog content route. It tells the library 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 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.

paragraph.config.ts
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.
nuxt.config.ts
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.

app/components/blog/Blog.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>
app/components/blog/Post.vue
<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.

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

server/api/blog.get.ts
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;
});
server/api/[locale]/blog.get.ts
export { default } from "../blog.get";
app/pages/blog/index.vue
<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.

server/api/blog/[slug].get.ts
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;
});
server/api/[locale]/blog/[slug].get.ts
export { default } from "../../blog/[slug].get";
app/pages/blog/[slug].vue
<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.

server/routes/robots.txt.ts
import { seo } from "../../paragraph.config";

export default defineEventHandler(async () =>
  new Response(await seo.robotsTxt(), {
    headers: {
      "content-type": "text/plain; charset=utf-8",
    },
  }),
);
server/routes/sitemap.xml.ts
import { seo } from "../../paragraph.config";

export default defineEventHandler(async () =>
  new Response(await seo.sitemapXml(), {
    headers: {
      "content-type": "application/xml; charset=utf-8",
    },
  }),
);
server/routes/llms.txt.ts
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:

server/routes/blog/rss.xml.ts
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:

server/routes/[locale]/blog/rss.xml.ts
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.

React Router

Build a localized React Router blog with Paragraph CMS from scratch, including prerendered routes, sitemap.xml, robots.txt, llms.txt, and RSS output.

SvelteKit

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

On this page

Install the packagesSet environment variablesCreate paragraph.config.tsConfigure nuxt.config.tsCreate example componentsCreate the server Paragraph helperBuild /blogBuild /blog/[slug]Add robots.txt, sitemap.xml, and llms.txtAdd RSS for the default locale and translated localesVerify the finished setup