• Features
  • Pricing
Get Started

Next.jsAstroReact RouterNuxtSvelteKitManual

Quickstart

Nuxt

SSR plus Nitro prerendering in Nuxt for a simple Paragraph CMS blog.

This quickstart uses Nuxt's file-based routing and Nitro server routes and walks through the core setup for fetching and rendering Paragraph CMS content in a Nuxt app, using SSR as the baseline and Nitro prerendering for known /blog/[slug] routes because the slug list can be derived from the CMS ahead of time while the API key stays on the server.

Want a ready-made project instead? Use the paragraphcms/nuxt-starter, which ships with the same integration pattern already wired into a blog.

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

Install the Package

Add the Paragraph CMS client and Vue renderer to your Nuxt app:

npm install @paragraphcms/client @paragraphcms/parser-vue
pnpm add @paragraphcms/client @paragraphcms/parser-vue
yarn add @paragraphcms/client @paragraphcms/parser-vue
bun add @paragraphcms/client @paragraphcms/parser-vue

Set Environment Variables

Create a .env file in the project root:

.env
NUXT_PARAGRAPH_API_KEY=your_api_key

Create a Paragraph CMS Client

Create server/utils/paragraph.ts and initialize a shared client. In Nuxt, private runtime variables are read through useRuntimeConfig():

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;
}

Configure Prerendering

Update nuxt.config.ts so Nuxt can read the API key and prerender every /blog/[slug] route returned by Paragraph CMS:

nuxt.config.ts
import { Client } from "@paragraphcms/client";

export default defineNuxtConfig({
  runtimeConfig: {
    paragraphApiKey: process.env.NUXT_PARAGRAPH_API_KEY,
  },
  hooks: {
    async "prerender:routes"(ctx) {
      const apiKey = process.env.NUXT_PARAGRAPH_API_KEY;

      if (!apiKey) {
        return;
      }

      const client = new Client({ apiKey });
      const { data: pages, error } = await client.pages.list({
        requiredSlug: true,
      });

      if (error) {
        throw error;
      }

      for (const page of pages) {
        ctx.routes.add(`/blog/${page.slug}`);
      }
    },
  },
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ["/blog"],
    },
  },
});

This mirrors the nuxt generate flow and makes sure dynamic CMS routes are included in the static output.

Create Example Components

Create two very simple components that we will render in the next steps:

app/components/blog/Blog.vue
<script setup lang="ts">
import type { PageSummaryWithSlug } from "@paragraphcms/client";

defineProps<{
  posts: PageSummaryWithSlug[];
}>();
</script>

<template>
  <main>
    <h1>Blog</h1>

    <ul>
      <li v-for="post in posts" :key="post.id">
        <NuxtLink :to="`/blog/${post.slug}`">{{ post.title }}</NuxtLink>
      </li>
    </ul>
  </main>
</template>
app/components/blog/Post.vue
<script setup lang="ts">
import type { PageWithSlug } from "@paragraphcms/client";
import { ParagraphContent } from "@paragraphcms/parser-vue";

defineProps<{
  page: PageWithSlug;
}>();
</script>

<template>
  <main>
    <h1>{{ page.title }}</h1>
    <ParagraphContent :content="page.content" />
  </main>
</template>

Build /blog

Keep the Paragraph API key on the server by creating a Nitro route that loads posts, then fetch it from the page with useAsyncData():

server/api/blog.get.ts
import { defineEventHandler } from "h3";
import { getParagraphClient } from "../utils/paragraph";

export default defineEventHandler(async () => {
  const client = getParagraphClient();
  const { data: posts, error } = await client.pages.list({
    requiredSlug: true,
  });

  if (error) {
    throw error;
  }

  return posts;
});
app/pages/blog/index.vue
<script setup lang="ts">
import type { PageSummaryWithSlug } from "@paragraphcms/client";
import Blog from "../../components/blog/Blog.vue";

const { data: posts, error } = await useAsyncData<PageSummaryWithSlug[]>(
  "blog-posts",
  () => $fetch("/api/blog"),
);

if (error.value) {
  throw error.value;
}
</script>

<template>
  <Blog :posts="posts ?? []" />
</template>

Because requiredSlug: true is set, the /api/blog 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 a single 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 route that resolves the requested page by slug, then fetch it from the dynamic page component:

server/api/blog/[slug].get.ts
import {
  createError,
  defineEventHandler,
  getRouterParam,
} from "h3";
import { getParagraphClient } from "../../utils/paragraph";

export default defineEventHandler(async (event) => {
  const slug = getRouterParam(event, "slug")?.trim();

  if (!slug) {
    throw createError({
      statusCode: 404,
      statusMessage: "Post not found",
    });
  }

  const client = getParagraphClient();
  const { data: page, error } = await client.page.getBySlug(slug);

  if (error) {
    throw error;
  }

  return page;
});
app/pages/blog/[slug].vue
<script setup lang="ts">
import type { PageWithSlug } from "@paragraphcms/client";
import Post from "../../components/blog/Post.vue";

const route = useRoute();
const slug = String(route.params.slug ?? "");

const { data: page, error } = await useAsyncData<PageWithSlug>(
  `blog-post:${slug}`,
  () => $fetch(`/api/blog/${encodeURIComponent(slug)}`),
);

if (error.value) {
  throw error.value;
}
</script>

<template>
  <Post v-if="page" :page="page" />
</template>

This keeps the route aligned with the slugs stored in Paragraph CMS, and the prerender hook will emit a static page for each one during nuxt generate.

Once this is wired, the rest of the page can be standard Nuxt UI around the rendered Paragraph content.

React Router

SSR with React Router framework-mode loaders for a simple Paragraph CMS blog.

SvelteKit

SSR with SvelteKit server loads for a simple Paragraph CMS blog.

On this page

Install the PackageSet Environment VariablesCreate a Paragraph CMS ClientConfigure PrerenderingCreate Example ComponentsBuild /blogBuild /blog/[slug]