WrytzeWrytze Docs
Guides

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.

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

.env
NUXT_WRYTZE_API_KEY=wrz_your_api_key_here
NUXT_PUBLIC_WRYTZE_WEBSITE_ID=ws_your_website_id_here

Nuxt 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.

server/api/blogs/[...].ts
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.

composables/useWrytze.ts
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.

pages/blog/index.vue
<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>&middot;</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.

pages/blog/[slug].vue
<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>&middot;</span>
          <span>{{ response.data.readingTimeMinutes }} min read</span>
          <span>&middot;</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/typography

In Tailwind CSS v4, plugins are added via @plugin in your CSS file:

assets/css/main.css
@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

Next steps

On this page