Build a blog
Create typed post routes, previous/next links, and a paginated index.
Use a page collection for post routes and a regular Nuxt page for the index. Start with the collection.
import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/config'
import { z } from 'zod'
export const posts = defineCollection({
type: 'page',
source: 'blog/**/*.md',
route: '/blog',
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.iso.datetime(),
tags: z.array(z.string()).default([]),
image: z.string().optional()
})
})
export default defineContentConfig({ collections: { posts } })Every file under content/blog/ now becomes a post at /blog/<slug>, and its frontmatter is checked against the schema. publishedAt is validated as an ISO datetime, so you can sort on it reliably. tags defaults to an empty array when you omit it.
Write a post
---
title: First post
description: Why the content model stays collection-first.
publishedAt: 2026-07-14T08:00:00.000Z
tags:
- architecture
---
# First post
The post body is Markdown.Render the post
useContentPage loads the post for the current route. Pass surround to also get the neighbouring posts for previous and next links.
<script setup lang="ts">
import { createError } from '#imports'
import { posts } from '~~/content.config'
definePageMeta({ key: route => route.path })
const { page: post, previous, next } = await useContentPage(posts, {
surround: { select: ['description', 'publishedAt'] }
})
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
</script>
<template>
<article v-if="post">
<ContentRenderer :value="post" />
<nav aria-label="Adjacent posts">
<NuxtLink v-if="previous" :to="previous.path">
Previous: {{ previous.title }}
</NuxtLink>
<NuxtLink v-if="next" :to="next.path">
Next: {{ next.title }}
</NuxtLink>
</nav>
</article>
</template>The guards hide the previous link on the first post and the next link on the last.
Build a paginated index
paginate returns one page of results plus paging metadata. Read the page number from route.query.page, and pass it to watch so the query re-runs when the URL changes. Give useAsyncData a key that includes the page number, so each page caches separately.
<script setup lang="ts">
import { paginate } from '@lupinum/ginko-content/client'
import { posts } from '~~/content.config'
const route = useRoute()
const pageNumber = computed(() =>
Math.max(1, Number.parseInt(String(route.query.page || 1), 10) || 1)
)
const { data: result } = await useAsyncData(
() => `blog:${pageNumber.value}`,
() => paginate(posts, {
mode: 'offset',
page: pageNumber.value,
limit: 12,
sort: { publishedAt: 'desc' },
select: ['title', 'description', 'publishedAt', 'tags', 'image']
}),
{ watch: [pageNumber] }
)
</script>
<template>
<article v-for="post in result?.data" :key="post.canonicalKey">
<NuxtLink :to="post.route.resolvedPath">{{ post.title }}</NuxtLink>
<p>{{ post.description }}</p>
</article>
</template>Link each card with post.route.resolvedPath, and use post.canonicalKey as the v-for key. Both come back even when you narrow the fields with select, so the card always knows where it points.
Set draft: true in a post's frontmatter to keep it out of the production build while you finish it.