genko.meDocs
Frameworks and languagesSvelteKit

Tutorial

Build pagination, saved draft previews, and webhook handling with SvelteKit server routes.

Add production features to the list and detail pages from Get started.

Pagination

The server fetches the next page. Add one POST handler in +server.ts.

src/routes/api/genko/page/+server.ts
export { genkoPaginationHandler as POST } from "@genko-me/svelte/server";

Set pagination in +page.server.ts. { mode: "url" } puts the page number in the URL and supports browser Back. The default (pagination: true) replaces the list without changing the URL.

src/routes/blog/+page.server.ts
import { fetchGenkoList } from "@genko-me/svelte/server";import { client } from "$lib/client";import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ url }) => ({  list: await fetchGenkoList({    api: client.blog,    fields: ["title", "publishedAt"],    href: "/blog/{id}",    limit: 10,    orders: "-publishedAt",    pagination: { mode: "url" },    searchParams: url.searchParams,  }),});

With searchParams, opening /blog?page=3 directly returns page three from the server.

src/routes/blog/+page.svelte
<script lang="ts">  import {    ListClient, ListItem, Pagination, Error, Null, Loading, createGenkoPageLoader,  } from "@genko-me/svelte";
  let { data } = $props();</script>
{#if data.list.status === "ok"}  <ListClient    {...data.list.data}    href={'/blog/{id}'}    paginationOptions={{ mode: 'url' }}    loadPage={createGenkoPageLoader({ token: data.list.data.paginationToken! })}  >    {#snippet children()}      <article>        <h2><ListItem id="title" /></h2>        <time><ListItem id="publishedAt" format="yyyy/MM/dd" timeZone="Asia/Tokyo" /></time>      </article>    {/snippet}    {#snippet pagination()}<Pagination />{/snippet}    {#snippet loading()}<Loading template />{/snippet}    {#snippet error()}<Error template />{/snippet}    {#snippet empty()}<Null>No published entries yet.</Null>{/snippet}  </ListClient>{:else}  <Error template />{/if}

Set mode and param on ListClient, not on Pagination. React reads <Pagination mode="url" /> from JSX, but Svelte cannot inspect snippets. Pagination accepts only previousLabel, nextLabel, class, and style. Match fetchGenkoList's pagination to ListClient's paginationOptions.

The default endpoint for createGenkoPageLoader is /api/genko/page. Set it if you use another path. Pagination renders nothing when the list has only one page.

About pagination tokens

fetchGenkoList issues a signed paginationToken on the server and sends it with page data to the browser. SvelteKit cannot bind arguments to a Server Action as Next.js does, so it uses the token.

  • The token contains no API key. It identifies only the fetch target and filters at issuance.
  • It expires after one hour. Reload if it expires or the key changes.
  • It can fetch only later pages of the list already shown.
  • Pagination does not work with a client configured with custom fetch or wrapRequest; that produces a configuration error.
  • Match the pagination client's API key to the server's GENKO_API_KEY.

Without JavaScript

Pagination uses buttons and cannot switch pages without JavaScript. For a form, use genkoPaginationAction.

src/routes/blog/+page.server.ts
import { fetchGenkoList, genkoPaginationAction } from "@genko-me/svelte/server";
export const actions = { genkoPage: genkoPaginationAction };

The action returns { ok: true, result } or { ok: false, message }. Pass that to ListClient as initial.

src/routes/blog/+page.svelte
{#if data.list.status === "ok"}  {@const initial = form?.ok ? form.result : data.list.data.initial}  {@const current = Math.floor(initial.offset / initial.limit) + 1}  {@const lastPage = Math.max(1, Math.ceil(initial.totalCount / initial.limit))}
  <ListClient {...data.list.data} {initial} initialPage={current}>    {#snippet children()}<h2><ListItem id="title" /></h2>{/snippet}    {#snippet empty()}<Null template />{/snippet}  </ListClient>
  <form method="POST" action="?/genkoPage">    <input type="hidden" name="token" value={data.list.data.paginationToken} />    <button type="submit" name="page" value={current - 1} disabled={current <= 1}>Previous</button>    <span>{current} / {lastPage}</span>    <button type="submit" name="page" value={current + 1} disabled={current >= lastPage}>Next</button>  </form>{:else}  <Error template />{/if}

Putting {@const} immediately inside {#if} keeps the type narrowed by status === "ok" for the calculation. Without an explicit offset, the current page is offset / limit + 1.

You can also combine mode: "url" with links such as <a href="?page=2">. Each navigation runs load on the server, so it needs neither tokens nor actions.

Preview drafts

Pass searchParams to fetchGenkoEntry so it reads draftKey from the URL and fetches the draft without caching. It returns draft: true.

src/routes/blog/[id]/+page.server.ts
import { error } from "@sveltejs/kit";import { fetchGenkoEntry } from "@genko-me/svelte/server";import { client } from "$lib/client";import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params, url }) => {  const entry = await fetchGenkoEntry({    api: client.blog,    id: params.id,    fields: ["title", "body"],    searchParams: url.searchParams,  });  if (entry.status === "empty") error(404, "Entry not found.");  return { entry };};
src/routes/blog/[id]/+page.svelte
<script lang="ts">  import { EntryProvider, ViewItem, PreviewNotice, ShareButtons, Error } from "@genko-me/svelte";  import { page } from "$app/state";
  let { data } = $props();
  const draft = $derived(data.entry.status === "ok" && data.entry.data.draft);</script>
<svelte:head>  {#if draft}    <meta name="robots" content="noindex, nofollow" />    <meta name="referrer" content="no-referrer" />  {/if}</svelte:head>
{#if data.entry.status === "ok"}  {#if draft}<PreviewNotice />{/if}  <EntryProvider {...data.entry.data}>    {#snippet children()}      <article>        <h1><ViewItem id="title" /></h1>        <ViewItem id="body" />      </article>    {/snippet}  </EntryProvider>  {#if !draft}    <ShareButtons url={page.url.href} title="Entry title" />  {/if}{:else}  <Error template />{/if}

Do not put <svelte:head> inside {#if}. Put it directly under the component, with {#if} inside the head tag. Otherwise compilation fails with <svelte:head> tags cannot be inside elements or blocks.

Set a preview URL in the dashboard API settings (/w/your-workspace/apis/blog/settings).

text
https://your-site.example/blog/{id}?draftKey={key}

Save an entry and open Preview to see the draft on the same detail page. No extra Route Handler or proxy is needed. The PreviewNotice exit button removes draftKey from the URL and reloads.

An invalid key returns a 404, not the published entry. fetchGenkoEntry returns status: "empty". The preview URL contains a key that can read the draft, so hide share buttons and canonical URLs during preview. Regenerating the key in an entry's Settings invalidates older preview URLs.

Revalidate with webhooks

SvelteKit has no equivalent of Next.js revalidatePath. The action depends on how you serve the page.

Delivery methodWhat to do after saving an entry
Server-rendered on every request (default)Nothing. fetchGenkoList and fetchGenkoEntry default to cache: false and fetch fresh data each time.
Built with export const prerender = truePoint the dashboard webhook at your hosting provider's deploy hook. No receiver code is needed.
Cached by a CDN or reverse proxyAdd a receiver that calls its purge API.

With a cache duration in seconds, requests omit no-store. SvelteKit has no next: { revalidate } option, so combine this with setHeaders in load.

src/routes/blog/+page.server.ts
export const load: PageServerLoad = async ({ setHeaders }) => {  const list = await fetchGenkoList({ api: client.blog, fields: ["title"], limit: 10, cache: 60 });  setHeaders({ "cache-control": "public, max-age=0, s-maxage=60" });  return { list };};

Draft previews always fetch fresh data, regardless of the cache setting.

Write a webhook receiver

If you need to trigger a purge or rebuild, receive the webhook in +server.ts. Verify the signature against the raw body before parsing JSON.

src/routes/api/genko/webhook/+server.ts
import { json, text } from "@sveltejs/kit";import { verifyGenkoSignature } from "@genko-me/sdk";import { env } from "$env/dynamic/private";import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request }) => {  const secret = env.GENKO_WEBHOOK_SECRET;  if (!secret) return text("Webhook secret is not configured", { status: 500 });
  const rawBody = await request.text();  const valid = await verifyGenkoSignature({    secret,    rawBody,    signatureHeader: request.headers.get("x-genko-signature"),  timestampHeader: request.headers.get("x-genko-timestamp"),  });  if (!valid) return text("Invalid signature", { status: 401 });
  const payload = JSON.parse(rawBody) as { event: string; api: string; entry: { id: string } | null };  // Purge the cache or trigger a rebuild here.  return json({ received: true, event: payload.event });};

Deletion notifications and test notifications with entry: null can arrive. The timeout is 10 seconds and there are no automatic retries. See Webhooks for events, payloads, and delivery limits.

Next steps

Last updated: October 14, 2026

On this page