Nuxt
Integrate Wrytze blogs into a Nuxt 3 application.
This guide walks you through building a complete blog section in a Nuxt 3 application. You will create a reusable composable for data fetching, a blog listing page with pagination, individual blog post pages, and a server route proxy to keep your API key secure.
Prerequisites
- A Nuxt 3 project
- A Wrytze API key (see Quickstart)
- Your Wrytze website ID (found in Settings > Website in the dashboard)
Configure runtime config
Add your Wrytze credentials to the Nuxt runtime config. This keeps the API key server-side only while making the website ID available on both server and client.
export default defineNuxtConfig({
runtimeConfig: {
wrytzeApiKey: '', // Set via NUXT_WRYTZE_API_KEY env var
public: {
wrytzeWebsiteId: '', // Set via NUXT_PUBLIC_WRYTZE_WEBSITE_ID env var
},
},
})Then create a .env file at the root of your project:
NUXT_WRYTZE_API_KEY=wrz_your_api_key_here
NUXT_PUBLIC_WRYTZE_WEBSITE_ID=ws_your_website_id_hereNuxt automatically maps environment variables prefixed with NUXT_ to matching keys in runtimeConfig. The NUXT_WRYTZE_API_KEY variable maps to runtimeConfig.wrytzeApiKey and is only available on the server. The NUXT_PUBLIC_WRYTZE_WEBSITE_ID variable maps to runtimeConfig.public.wrytzeWebsiteId and is available on both server and client.
Create a server route proxy
Create a server API route that proxies requests to the Wrytze API. This keeps your API key on the server and gives your frontend a local endpoint to call.
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// Build the path from the catch-all parameter
const path = event.context.params?._ ?? ''
const query = getQuery(event)
const queryString = new URLSearchParams(
Object.entries(query).reduce<Record<string, string>>(
(acc, [key, value]) => {
if (value !== undefined && value !== null) {
acc[key] = String(value)
}
return acc
},
{}
)
).toString()
const url = `https://app.wrytze.com/api/v1/blogs/${path}${
queryString ? `?${queryString}` : ''
}`
const response = await $fetch(url, {
headers: {
'X-API-Key': config.wrytzeApiKey,
},
})
return response
})The catch-all route [...].ts forwards any sub-path to the Wrytze API. For example, a request to /api/blogs/slug/my-post proxies to https://app.wrytze.com/api/v1/blogs/slug/my-post. This means your frontend never touches the API key directly.
Create the Wrytze composable
Create a composable that wraps useFetch to provide typed access to the Wrytze API through your server proxy.
interface Blog {
id: string
title: string
slug: string
excerpt: string | null
contentHtml: string
metaTitle: string
metaDescription: string
featuredImageUrl: string | null
featuredImageAlt: string | null
wordCount: number
readingTimeMinutes: number
publishedAt: string
websiteId: string
categories: Array<{ name: string; slug: string }>
tags: Array<{ name: string; slug: string }>
}
interface Pagination {
page: number
limit: number
total: number
pages: number
}
interface BlogListResponse {
data: Blog[]
pagination: Pagination
}
interface BlogDetailResponse {
data: Blog
}
interface ListOptions {
page?: number
limit?: number
category?: string
tag?: string
search?: string
}
export function useWrytzeBlogs(options: ListOptions = {}) {
const config = useRuntimeConfig()
const websiteId = config.public.wrytzeWebsiteId
const query = computed(() => ({
website_id: websiteId,
page: options.page ?? 1,
limit: options.limit ?? 12,
...(options.category && { category: options.category }),
...(options.tag && { tag: options.tag }),
...(options.search && { search: options.search }),
}))
return useFetch<BlogListResponse>('/api/blogs', {
query,
})
}
export function useWrytzeBlog(slug: string) {
const config = useRuntimeConfig()
const websiteId = config.public.wrytzeWebsiteId
return useFetch<BlogDetailResponse>(`/api/blogs/slug/${slug}`, {
query: { website_id: websiteId },
})
}Build the blog listing page
Create the blog listing page with pagination support.
<script setup lang="ts">
const route = useRoute()
const page = computed(() => {
const p = parseInt(route.query.page as string, 10)
return isNaN(p) || p < 1 ? 1 : p
})
const { data: response, status } = useWrytzeBlogs({
page: page.value,
limit: 12,
})
// SEO
useHead({
title: 'Blog',
meta: [
{
name: 'description',
content: 'Read our latest articles and insights.',
},
],
})
</script>
<template>
<main class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<h1 class="mb-8 text-4xl font-bold tracking-tight">Blog</h1>
<!-- Loading state -->
<div v-if="status === 'pending'" class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="i in 6" :key="i" class="space-y-4">
<div class="aspect-[16/9] animate-pulse rounded-lg bg-gray-200" />
<div class="h-6 w-full animate-pulse rounded bg-gray-200" />
<div class="h-4 w-3/4 animate-pulse rounded bg-gray-200" />
</div>
</div>
<!-- Blog grid -->
<div v-else-if="response?.data" class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
<article v-for="blog in response.data" :key="blog.id" class="group">
<NuxtLink :to="`/blog/${blog.slug}`" class="block">
<div
v-if="blog.featuredImageUrl"
class="relative mb-4 aspect-[16/9] overflow-hidden rounded-lg"
>
<img
:src="blog.featuredImageUrl"
:alt="blog.featuredImageAlt ?? blog.title"
class="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<div v-if="blog.categories.length" class="mb-2 flex gap-2">
<span
v-for="cat in blog.categories"
:key="cat.slug"
class="text-xs font-medium uppercase tracking-wide text-blue-600"
>
{{ cat.name }}
</span>
</div>
<h2 class="mb-2 text-xl font-semibold group-hover:underline">
{{ blog.title }}
</h2>
<p v-if="blog.excerpt" class="mb-3 line-clamp-2 text-gray-600">
{{ blog.excerpt }}
</p>
<div class="flex items-center gap-3 text-sm text-gray-500">
<time :datetime="blog.publishedAt">
{{
new Date(blog.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}}
</time>
<span>·</span>
<span>{{ blog.readingTimeMinutes }} min read</span>
</div>
</NuxtLink>
</article>
</div>
<!-- Pagination -->
<nav
v-if="response?.pagination && response.pagination.pages > 1"
class="mt-12 flex items-center justify-center gap-4"
>
<NuxtLink
v-if="page > 1"
:to="{ query: { page: page - 1 } }"
class="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50"
>
Previous
</NuxtLink>
<span class="text-sm text-gray-600">
Page {{ page }} of {{ response.pagination.pages }}
</span>
<NuxtLink
v-if="page < response.pagination.pages"
:to="{ query: { page: page + 1 } }"
class="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-gray-50"
>
Next
</NuxtLink>
</nav>
</main>
</template>Build the blog detail page
Create the individual blog post page with dynamic routing and full SEO metadata.
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug as string
const { data: response, status } = useWrytzeBlog(slug)
// SEO metadata
useSeoMeta({
title: () => response.value?.data?.metaTitle ?? 'Blog Post',
ogTitle: () => response.value?.data?.metaTitle ?? 'Blog Post',
description: () => response.value?.data?.metaDescription ?? '',
ogDescription: () => response.value?.data?.metaDescription ?? '',
ogImage: () => response.value?.data?.featuredImageUrl ?? '',
ogType: 'article',
twitterCard: 'summary_large_image',
})
</script>
<template>
<main class="mx-auto max-w-3xl px-4 py-12 sm:px-6 lg:px-8">
<!-- Loading state -->
<div v-if="status === 'pending'" class="space-y-6">
<div class="h-8 w-24 animate-pulse rounded-full bg-gray-200" />
<div class="h-12 w-full animate-pulse rounded bg-gray-200" />
<div class="h-4 w-48 animate-pulse rounded bg-gray-200" />
<div class="aspect-[16/9] animate-pulse rounded-xl bg-gray-200" />
</div>
<!-- Blog post -->
<template v-else-if="response?.data">
<!-- Header -->
<header class="mb-8">
<div v-if="response.data.categories.length" class="mb-4 flex gap-2">
<NuxtLink
v-for="cat in response.data.categories"
:key="cat.slug"
:to="`/blog?category=${cat.slug}`"
class="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 hover:bg-blue-200"
>
{{ cat.name }}
</NuxtLink>
</div>
<h1 class="mb-4 text-4xl font-bold tracking-tight lg:text-5xl">
{{ response.data.title }}
</h1>
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<time :datetime="response.data.publishedAt">
{{
new Date(response.data.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}}
</time>
<span>·</span>
<span>{{ response.data.readingTimeMinutes }} min read</span>
<span>·</span>
<span>{{ response.data.wordCount.toLocaleString() }} words</span>
</div>
</header>
<!-- Featured image -->
<div
v-if="response.data.featuredImageUrl"
class="relative mb-10 aspect-[16/9] overflow-hidden rounded-xl"
>
<img
:src="response.data.featuredImageUrl"
:alt="response.data.featuredImageAlt ?? response.data.title"
class="h-full w-full object-cover"
/>
</div>
<!-- Blog content -->
<article
class="prose prose-lg max-w-none prose-headings:scroll-mt-20 prose-a:text-blue-600 prose-img:rounded-lg"
v-html="response.data.contentHtml"
/>
<!-- Tags -->
<footer v-if="response.data.tags.length" class="mt-12 border-t pt-6">
<div class="flex flex-wrap gap-2">
<NuxtLink
v-for="tag in response.data.tags"
:key="tag.slug"
:to="`/blog?tag=${tag.slug}`"
class="rounded-full border px-3 py-1 text-sm text-gray-600 hover:bg-gray-50"
>
#{{ tag.name }}
</NuxtLink>
</div>
</footer>
</template>
<!-- Not found -->
<div v-else class="py-20 text-center">
<h1 class="mb-4 text-2xl font-bold">Blog post not found</h1>
<NuxtLink to="/blog" class="text-blue-600 hover:underline">
Back to blog
</NuxtLink>
</div>
</main>
</template>Enable the typography plugin
Install and configure @tailwindcss/typography so the prose classes correctly style the blog HTML content.
npm install @tailwindcss/typographyIn Tailwind CSS v4, plugins are added via @plugin in your CSS file:
@import "tailwindcss";
@plugin "@tailwindcss/typography";Full project structure
After completing all the steps above, your blog-related files should look like this:
your-nuxt-app/
├── composables/
│ └── useWrytze.ts # Wrytze composable
├── pages/
│ └── blog/
│ ├── index.vue # Blog listing
│ └── [slug].vue # Blog post
├── server/
│ └── api/
│ └── blogs/
│ └── [...].ts # API proxy route
├── nuxt.config.ts # Runtime config
└── .env # API key & website ID