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-vuepnpm add @paragraphcms/client @paragraphcms/parser-vueyarn add @paragraphcms/client @paragraphcms/parser-vuebun add @paragraphcms/client @paragraphcms/parser-vueSet Environment Variables
Create a .env file in the project root:
NUXT_PARAGRAPH_API_KEY=your_api_keyCreate a Paragraph CMS Client
Create server/utils/paragraph.ts and initialize a shared client. In
Nuxt, private runtime variables are read through useRuntimeConfig():
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:
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:
<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><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():
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;
});<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:
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;
});<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.