Blog navigatie op detailpagina

Blog navigatie op detailpagina

Sjoerd 2 februari 2026

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 (_lt en _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:

  1. Minimal data transfer: Alleen 3 velden per post
  2. Database indexed queries: Date filtering met index
  3. Limit 1: Stopt na eerste match
  4. Client-side caching: Browser cacht API responses
  5. 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:

  1. Server API endpoint
  2. Directus REST queries
  3. Frontend data fetching
  4. 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.