Blog navigatie op detailpagina
In Drupal heb je Views waarin je vorige/volgende post navigatie kunt verzorgen. In een headless setup met Directus als CMS en Nuxt als frontend moet je dit zelf bouwen. Klinkt simpel, maar Nuxt's SSR (Server-Side Rendering) context maakt het lastiger dan verwacht.
Waarom is dit complex?
Drupal vs Headless
In Drupal:
- Views module genereert automatisch prev/next links
- Alles gebeurt server-side in PHP
- Data en presentatie zitten in één systeem
In Directus + Nuxt:
- Backend (Directus) en frontend (Nuxt) zijn gescheiden
- Nuxt moet expliciet API calls doen naar Directus
- Code draait zowel server-side (SSR) als client-side
- Dependencies en lifecycle zijn anders per context
SSR vs Client-side rendering
Nuxt genereert HTML op de server (SSR) voordat de browser het ziet. Dit betekent:
setup()in Vue components draait tijdens build/SSR- Niet alle browser APIs zijn beschikbaar
- Async data moet op specifieke manieren opgehaald worden
watch()en lifecycle hooks gedragen zich anders
De mislukte pogingen
Poging 1: Separate useAsyncData met date filters
const { data: prevPosts } = await useAsyncData(`prev-${slug}`, () =>
getItems({
filter: {
date_created: { _lt: post.value.date_created }
}
})
)
Probleem: post.value is nog niet beschikbaar tijdens de initiële useAsyncData call. Het is undefined omdat de main post query nog niet klaar is.
Poging 2: watch() met ref() voor dynamic fetching
const previousPost = ref(null)
watch(post, async (currentPost) => {
if (!currentPost?.date_created) return
const result = await getItems({...})
previousPost.value = result[0]
})
Probleem: watch() triggert niet consistent tijdens SSR. Het is bedoeld voor client-side reactivity, niet voor server-side data fetching.
Poging 3: useAsyncData met watch optie
const { data: prevPosts } = await useAsyncData(
`prev-${slug}`,
async () => { /* fetch logic */ },
{ watch: [posts] }
)
Probleem: De watch optie werkt niet zoals verwacht in SSR context. De dependency tracking is niet betrouwbaar.
Poging 4: Alle posts ophalen en filteren
const allPosts = await getItems({ /* alle posts */ })
const currentIndex = allPosts.findIndex(p => p.id === currentPost.id)
const previousPost = allPosts[currentIndex + 1]
Werkt, maar: Haalt ALLE posts op. Voor 3 posts geen probleem, maar voor 1000 posts zeer inefficient. Niet schaalbaar.
Poging 5: Server API route met SDK
// server/api/post-navigation.ts
import { createDirectus, rest, readItems } from '@directus/sdk'
export default defineEventHandler(async (event) => {
const directus = createDirectus(url).with(rest())
// query logic
})
Probleem: Nitro (Nuxt's server engine) bundelt dependencies niet automatisch. De @directus/sdk package werd niet gevonden in de build output:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@directus/sdk'
Poging 6: Server API met runtimeConfig
const config = useRuntimeConfig()
const directusUrl = config.public.directusUrl
Probleem: useRuntimeConfig() is undefined in server API context. Runtime config is niet beschikbaar op dezelfde manier als in components.
De werkende oplossing
Architectuur
Server API route (server/api/post-navigation.js):
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const { postId, dateCreated } = query
const directusUrl = 'https://admin.desktoptraveller.nl'
// Previous post (older)
const prevResponse = await $fetch(`${directusUrl}/items/Blogposts`, {
params: {
filter: JSON.stringify({
status: { _eq: 'published' },
date_created: { _lt: dateCreated }
}),
sort: '-date_created',
limit: 1,
fields: 'id,title,slug'
}
})
// Next post (newer)
const nextResponse = await $fetch(`${directusUrl}/items/Blogposts`, {
params: {
filter: {
status: { _eq: 'published' },
date_created: { _gt: dateCreated }
},
sort: 'date_created',
limit: 1,
fields: 'id,title,slug'
}
})
return {
previous: prevResponse?.data?.[0] || null,
next: nextResponse?.data?.[0] || null
}
})
Frontend component (pages/blog/[slug].vue):
const post = computed(() => posts.value?.[0])
const navPrevious = ref(null)
const navNext = ref(null)
onMounted(async () => {
if (post.value?.id && post.value?.date_created) {
const nav = await $fetch('/api/post-navigation', {
params: {
postId: post.value.id,
dateCreated: post.value.date_created
}
})
navPrevious.value = nav.previous
navNext.value = nav.next
}
})
Waarom werkt dit?
1. Scheiding van concerns
- Server API doet de Directus queries
- Frontend component doet alleen presentation logic
- Geen SDK dependencies in client bundle
2. Directe Directus REST API calls
- Gebruikt Nuxt's ingebouwde
$fetch - Geen externe dependencies nodig
- Werkt in server context
3. Client-side navigatie fetch
onMounted()draait alleen client-side- Post data is al beschikbaar (via SSR)
- Navigatie laadt asynchroon na page render
- Geen SSR timing issues
4. Efficient queries
- Limit 1 per query
- Alleen benodigde velden (id, title, slug)
- Date-based filtering (
_lten_gt) - Schaalt naar duizenden posts
Build & deploy workflow
Cruciale stap: na wijzigingen in server/ directory moet je rebuilden:
npm run build
sudo systemctl restart nuxt-desktoptraveller.service
Nuxt SSR apps draaien niet in dev mode in productie. De .output/ directory bevat de gecompileerde server:
.output/
server/
chunks/
routes/
api/
post-navigation.mjs # Gebundelde API route
index.mjs # Server entry point
Zonder rebuild blijft oude code actief.
Technische details
Directus REST API filters
Directus accepteert filters als JSON string in query params:
filter: JSON.stringify({
status: { _eq: 'published' },
date_created: { _lt: '2026-02-02T12:00:00' }
})
Operators:
_eq: equals_lt: less than (ouder)_gt: greater than (nieuwer)_neq,_in,_contains, etc.
Sort direction
-date_created: descending (nieuwste eerst)date_created: ascending (oudste eerst)
Voor previous post (ouder): filter op _lt + sort -date_created
Voor next post (nieuwer): filter op _gt + sort date_created
Response structuur
Directus REST API response:
{
"data": [
{
"id": 2,
"title": "Post titel",
"slug": "post-slug"
}
]
}
Daarom: prevResponse?.data?.[0]
Performance overwegingen
Deze oplossing is efficiënt omdat:
- Minimal data transfer: Alleen 3 velden per post
- Database indexed queries: Date filtering met index
- Limit 1: Stopt na eerste match
- Client-side caching: Browser cacht API responses
- Non-blocking: Navigatie laadt na main content
Voor 10.000 posts:
- Query time: ~10ms (met database index)
- Data transfer: ~200 bytes per request
- Totaal: 2 requests, ~20ms, ~400 bytes
Alternatieve aanpakken
Optie A: Static site generation (SSG)
Bij nuxt generate kun je alle navigatie pre-renderen:
export async function getStaticPaths() {
const posts = await getAllPosts()
return posts.map(post => ({
slug: post.slug,
navigation: getNavigation(post, posts)
}))
}
Voordeel: Zero runtime queries Nadeel: Rebuild nodig bij nieuwe posts
Optie B: Edge caching
Cache API responses op CDN/edge:
export default defineEventHandler(async (event) => {
setHeader(event, 'Cache-Control', 's-maxage=3600')
// query logic
})
Voordeel: Sneller bij veel verkeer Nadeel: Stale data mogelijk
Optie C: GraphQL met Directus
Directus ondersteunt ook GraphQL:
query GetNavigation($date: String!) {
previous: Blogposts(
filter: { date_created: { _lt: $date } }
sort: "-date_created"
limit: 1
) {
id
title
slug
}
next: Blogposts(
filter: { date_created: { _gt: $date } }
sort: "date_created"
limit: 1
) {
id
title
slug
}
}
Voordeel: Één query voor beide posts Nadeel: Extra dependency en complexity
Conclusie
Headless architectuur geeft flexibiliteit maar vereist expliciete data orchestration. Wat in Drupal één Views configuratie is, wordt in Nuxt + Directus:
- Server API endpoint
- Directus REST queries
- Frontend data fetching
- Build & deploy pipeline
De trade-off:
- Meer code en complexity
- Volledige controle over queries en performance
- Schaalbaar naar grote datasets
- Moderne developer experience
Voor simpele use cases kan dit overweldigend lijken. Voor grotere projecten met performance eisen is de granulaire controle waardevol.