genko.meDocs
Frameworks and languagesSvelte

Tutorial

Add pagination and draft previews to a Svelte SPA without SvelteKit, and choose how content updates reach the app.

Add production features to the list and detail pages from Get started. You can use @genko-me/svelte display components, but your app must decide when and what to fetch for pagination and preview.

Pagination

Pass ListClient a loadPage function that accepts a page number and returns PageResult. In SvelteKit, createGenkoPageLoader does this through server-signed tokens. Without a server, write a function that calls client.blog.list() directly.

src/components/BlogList.svelte
<script lang="ts">  import { onMount } from "svelte";  import { getApiBinding } from "@genko-me/sdk";  import { ListClient, ListItem, Pagination, Error, Null, Loading } from "@genko-me/svelte";  import type { ListClientProps, PageResult } from "@genko-me/svelte";  import { client } from "../lib/genko";
  const PER_PAGE = 10;  let listProps = $state<Omit<ListClientProps, "children"> | undefined>(undefined);  let failed = $state(false);
  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." };    }  }
  onMount(async () => {    const initialPage = Number(new URLSearchParams(window.location.search).get("page") ?? 1) || 1;    try {      const binding = getApiBinding(client.blog);      const initial = await client.blog.list({ limit: PER_PAGE, offset: (initialPage - 1) * PER_PAGE, orders: "-publishedAt" });      listProps = {        initial, initialPage, fields: binding.fields, schemas: binding.schemas,        href: "/blog/{id}", loadPage, paginationOptions: { mode: "url" },      };    } catch {      failed = true;    }  });</script>
<h1>Blog</h1>
{#if failed}  <Error template />{:else if !listProps}  <Loading template />{:else}  <ListClient {...listProps}>    {#snippet children()}<h2><ListItem id="title" /></h2>{/snippet}    {#snippet pagination()}<Pagination />{/snippet}    {#snippet empty()}<Null template />{/snippet}  </ListClient>{/if}

ListClient handles Pagination, URL synchronization, and aria-live announcements just as it does in SvelteKit. With paginationOptions={{ mode: "url" }}, it updates the URL's page through window.history.pushState and supports browser Back. By default, it changes only the list and leaves the URL alone.

This differs from React without Next.js. That setup has no Pagination component, so you write its buttons and page calculations. Svelte's display components work outside SvelteKit and can be reused here.

limit accepts 1–100. To fetch more than 100 entries at once, getAll() paginates automatically. See Sorting, filtering, and search.

Preview drafts

Read draftKey from the URL and pass it as the second argument to getOrNull. A valid key returns the draft; an invalid one produces a 404 and returns null.

src/components/BlogPost.svelte
<script lang="ts">  import { getApiBinding } from "@genko-me/sdk";  import { EntryProvider, ViewItem, PreviewNotice, ShareButtons, Error } from "@genko-me/svelte";  import type { EntryProviderProps } from "@genko-me/svelte";  import { client } from "../lib/genko";
  let { id }: { id: string } = $props();
  const draftKey = new URLSearchParams(window.location.search).get("draftKey") ?? undefined;
  let entryProps = $state<Omit<EntryProviderProps, "children"> | undefined>(undefined);  let notFound = $state(false);  let failed = $state(false);
  $effect(() => {    entryProps = undefined;    notFound = false;    failed = false;    const binding = getApiBinding(client.blog);    client.blog      .getOrNull(id, draftKey ? { draftKey } : undefined)      .then((entry) => {        if (!entry) { notFound = true; return; }        entryProps = { entry, fields: binding.fields, schemas: binding.schemas };      })      .catch(() => { failed = true; });  });</script>
<svelte:head>  {#if draftKey}    <meta name="robots" content="noindex, nofollow" />    <meta name="referrer" content="no-referrer" />  {/if}</svelte:head>
{#if failed}  <Error template />{:else if notFound}  <p>Entry not found.</p>{:else if entryProps}  {#if draftKey}<PreviewNotice />{/if}  <EntryProvider {...entryProps}>    {#snippet children()}      <article>        <h1><ViewItem id="title" /></h1>        <ViewItem id="body" />      </article>    {/snippet}  </EntryProvider>  {#if !draftKey}    <ShareButtons url={`https://your-site.example/blog/${id}`} title="Entry title" />  {/if}{/if}

<svelte:head> is not exclusive to SvelteKit. The Svelte compiler adds its content to the DOM <head> when the component mounts, so it works here too. Because this is not SSR, crawlers that do not run JavaScript might miss noindex. Draft previews should not normally be exposed to crawlers. If you need stronger protection, block the preview URL in robots.txt or put it behind authentication.

The PreviewNotice exit button removes draftKey with window.location.replace and reloads the entire page, rather than using soft SPA navigation.

In the dashboard API settings (/w/your-workspace/apis/blog/settings), set the preview URL template to your SPA route.

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

The dashboard replaces {id} and {key}. Configure an SPA fallback so the detail page opens directly. The preview URL contains a key that can read the draft; hide share buttons and canonical URLs during preview. Regenerating the 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 verifyGenkoSignature from @genko-me/sdk in an environment that can read the raw request body, such as Node.js. A browser-only app cannot host this function.
ts
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

Last updated: October 14, 2026

On this page