Tutorial
Add pagination and draft previews to Vue without Nuxt, and choose how content updates reach the app.
Add production features to the list and detail pages from Get started. This setup has no Nuxt server routes or signed pagination tokens, so the code calls @genko-me/sdk directly.
Pagination
Pass a function that calls @genko-me/sdk directly to ListClient's load-page. The API key is already in the browser, so signed tokens are unnecessary.
<script setup lang="ts">import { onMounted, ref } from "vue";import { getApiBinding } from "@genko-me/sdk";import type { GenkoListResult } from "@genko-me/sdk";import type { PageResult } from "@genko-me/vue";import { client } from "../lib/genko";
const PER_PAGE = 6;const binding = getApiBinding(client.blog);
const initial = ref<GenkoListResult<Record<string, unknown>>>();const failed = ref(false);
onMounted(async () => { try { initial.value = await client.blog.list({ limit: PER_PAGE, orders: "-publishedAt" }); } catch { failed.value = true; }});
async function loadPage(page: number): Promise<PageResult> { try { const result = await client.blog.list({ limit: PER_PAGE, offset: (page - 1) * PER_PAGE, orders: "-publishedAt" }); return { ok: true, result }; } catch { return { ok: false, message: "Could not load entries." }; }}</script>
<template> <Error v-if="failed" template /> <Loading v-else-if="!initial" template /> <ListClient v-else :initial="initial" :initial-page="1" :fields="binding.fields" :schemas="binding.schemas" href="/blog/{id}" :load-page="loadPage"> <article> <h2><ListItem id="title" /></h2> </article> <template #pagination><Pagination /></template> <template #empty><Null>No published entries yet.</Null></template> </ListClient></template>PageResult is { ok: true, result } or { ok: false, message }. When loadPage returns it, ListClient manages pending, error, and page state through its internal useListStore. limit accepts 1–100. To keep the page number in the URL, read page from Vue Router's query and implement the equivalent of mode="url" yourself. This setup has no dedicated URL mode like Nuxt's pagination-options.
Preview drafts
Read draftKey from the URL and pass it to getOrNull. A valid key returns the draft; an invalid key returns null.
<script setup lang="ts">import { computed, onMounted, ref, watchEffect } from "vue";import { useRoute } from "vue-router";import { getApiBinding } from "@genko-me/sdk";import type { GenkoEntryMeta } from "@genko-me/sdk";import { EntryProvider, PreviewNotice, ShareButtons, ViewItem } from "@genko-me/vue";import { client } from "../lib/genko";import type { Blog } from "../lib/schema";
const route = useRoute();const binding = getApiBinding(client.blog);
const post = ref<(Blog & GenkoEntryMeta) | null>();const draftKey = computed(() => (typeof route.query.draftKey === "string" ? route.query.draftKey : undefined));const draft = ref(false);
onMounted(async () => { try { post.value = await client.blog.getOrNull(route.params.id as string, draftKey.value ? { draftKey: draftKey.value } : undefined); draft.value = Boolean(draftKey.value) && post.value !== null; } catch { post.value = null; }});
// An SPA cannot return response headers or HTML meta tags from the server,// so add this to document.head only during preview.watchEffect(() => { let tag = document.querySelector('meta[name="robots"]'); if (draft.value) { if (!tag) { tag = document.createElement("meta"); tag.setAttribute("name", "robots"); document.head.appendChild(tag); } tag.setAttribute("content", "noindex, nofollow"); } else { tag?.remove(); }});</script>
<template> <p v-if="post === undefined">Loading…</p> <p v-else-if="post === null">Entry not found.</p> <EntryProvider v-else :entry="post" :fields="binding.fields" :schemas="binding.schemas"> <PreviewNotice v-if="draft" /> <h1><ViewItem id="title" /></h1> <ViewItem id="body" /> <ShareButtons v-if="!draft" :url="`https://your-site.example${route.fullPath}`" :title="post.title" /> </EntryProvider></template>In the dashboard API settings (/w/your-workspace/apis/blog/settings), set the preview URL to your SPA route.
https://your-site.example/blog/{id}?draftKey={key}The dashboard replaces {id} and {key}. Configure an SPA fallback so unknown paths return index.html.
The PreviewNotice exit button works here too. It calls window.location.replace() to reload after removing draftKey from the URL, without depending on a framework router. The Next.js View automation is unavailable, but the code above adds the notice and noindex behavior.
Prevent sharing draft links. A preview URL contains a key that can read the draft. The example hides ShareButtons while draft is set. Regenerating the preview key in an entry's Settings invalidates older preview URLs.
Revalidate with webhooks
There is no cache to revalidate in this setup. The SPA fetches from the delivery API each time it displays content, so published changes appear on the next view. You do not need a webhook receiver.
Use a webhook in these cases:
- Content is embedded at build time through static generation or prerendering. Point the dashboard webhook at your hosting provider's deploy hook to rebuild when an entry is saved. No receiver code is needed.
- A CDN or reverse proxy caches API responses. Add a small server function to call its purge API. You can verify signatures with
verifyGenkoSignaturefrom@genko-me/sdkin an environment that can read the raw request body, such as Node.js. Do not put it in browser code.
import { verifyGenkoSignature } from "@genko-me/sdk";
const rawBody = await request.text();const valid = await verifyGenkoSignature({ secret: process.env.GENKO_WEBHOOK_SECRET!, rawBody, signatureHeader: request.headers.get("x-genko-signature"), timestampHeader: request.headers.get("x-genko-timestamp"),});See Webhooks for event types, payloads, and delivery limits.
Next steps
- Sorting, filtering, and search
- Deliver images
- Delivery API
- Nuxt: When you need SSR and signed pagination
Last updated: October 14, 2026