Nuxt
Server-side rendering with 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 with server-side rendering (SSR). SSR is the recommended delivery model for Paragraph CMS because the API key and content requests stay 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 SSR
Update nuxt.config.ts so Nuxt reads the API key on the server and keeps
SSR enabled:
export default defineNuxtConfig({
runtimeConfig: {
paragraphApiKey: process.env.NUXT_PARAGRAPH_API_KEY,
},
ssr: true,
});Configure cache settings for the platform where the application runs.
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 resolves the requested slug on the server and keeps the API key out of the browser.
Once this is wired, the rest of the page can be standard Nuxt UI around the rendered Paragraph content.