genko.meDocs
Frameworks and languagesReact

Tutorial

Add pagination and draft previews to a React SPA without Next.js, and choose how content updates reach the app.

Add the features needed for production to the list and detail pages from Get started. Next.js components such as Pagination and View are unavailable here, so you implement them in your app.

Pagination

Keep limit and offset in state and calculate the last page from totalCount.

src/components/BlogPager.tsx
import { useEffect, useState } from "react";import type { GenkoListResult } from "@genko-me/sdk";import { client } from "../lib/genko";import type { Blog } from "../lib/schema";
const PER_PAGE = 10;
export function BlogPager() {  const [page, setPage] = useState(1);  const [result, setResult] = useState<GenkoListResult<Blog> | null>(null);
  useEffect(() => {    let active = true;    client.blog      .list({ limit: PER_PAGE, offset: (page - 1) * PER_PAGE, orders: "-publishedAt" })      .then((next) => { if (active) setResult(next); })      .catch(() => { if (active) setResult(null); });    return () => { active = false; };  }, [page]);
  if (!result) return <p>Loading…</p>;  const lastPage = Math.max(1, Math.ceil(result.totalCount / PER_PAGE));
  return (    <nav aria-label="Pagination">      <ol>        {result.contents.map((post) => (          <li key={post.id}><a href={`/blog/${post.id}`}>{post.title}</a></li>        ))}      </ol>      <button type="button" onClick={() => setPage((current) => current - 1)} disabled={page <= 1}>        Previous      </button>      <span aria-live="polite">{page} / {lastPage}</span>      <button type="button" onClick={() => setPage((current) => current + 1)} disabled={page >= lastPage}>        Next      </button>    </nav>  );}

limit accepts 1–100. For more entries, getAll() automatically fetches pages of 100. To keep the page number in the URL, read page from your router's query parameters. aria-live announces page changes to screen readers.

Preview drafts

Read draftKey from the URL and pass it to the fetch method. A valid key returns the draft; an invalid one produces a 404, so getOrNull() returns null.

src/components/BlogPost.tsx
const draftKey = new URLSearchParams(window.location.search).get("draftKey") ?? undefined;
useEffect(() => {  let active = true;  client.blog    .getOrNull(id, draftKey ? { draftKey } : undefined)    .then((result) => { if (active) setPost(result); })    .catch(() => { if (active) setPost(null); });  return () => { active = false; };}, [id, draftKey]);

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}. Make sure your hosting setup can open detail pages directly through an SPA fallback.

The Next.js View automation is unavailable here. Implement these three behaviors yourself:

  • Preview notice and exit. Show that the reader is viewing a draft and provide a way to remove draftKey and return to the published version.
  • Search protection. Add <meta name="robots" content="noindex, nofollow">. An SPA cannot return the header from the server, so add the tag to document.head only during preview.
  • Sharing protection. The preview URL contains a key that can read the draft. Hide share buttons and canonical URLs during preview.

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 verifyGenkoSignature from @genko-me/sdk in an environment that can read the raw request body, such as Node.js.
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