KV store

Add a fast and globally readable cache to store values as snapshots, settings and more, when needed. Goribu makes it easy to spin up your KV both in dev and production.

updated in 0.9.4
On this page

Adding KV to your app

KV is Cloudflare's key-value database (Cloudflare KV).To set up your store, add a kv entry to your existing config:

goribu.config.js
import { defineConfig } from "goribu/server";

export default defineConfig({
  name: "my-app",
  compatibilityDate: "2026-09-01",
  kv: { name: "my-app-kv" }, // Add this
});
  • Local namespace: pnpm dev uses a local version that is separate from production. Don't forget to restart the dev server after changing the config. pnpm exec goribu local:clear kv deletes its keys, or only some with --prefix.
  • Production namespace: Deployment creates the named namespace the first time and reuses it afterwards.
  • Config only: Declare KV here, not in a Wrangler file. Goribu manages the binding.

When to use KV

KV is eventually consistent. After a write or delete, other readers, and even the same visitor's next request, can see the old value or null for a while. According to Cloudflare's docs it can take up to 60 seconds or more. Deploying new code does not clear it.

  • Good fits: Values computed once and shown to everyone, such as homepage stats, a leaderboard or data fetched from another API, plus feature settings and anything else where slightly old data is fine.
  • Use D1 or Postgres instead: For records you own, exact counters, permissions or a flow that must show visitors their own changes right away.
  • Just caching a query? req.cache needs no setup and is a simpler fit for short-lived cached results.

Read and write

This route serves exchange rates from an external API, calling it at most once every 10 minutes:

src/routes/rates.ts
import type { Handler } from "goribu/server";

type Rates = { base: string; rates: Record<string, number> };

export const GET: Handler = async (req, res) => {
  let rates = await req.kv.get<Rates>("rates:usd", { type: "json" });

  if (rates === null) {
    // Example API; replace with the service you use.
    const response = await fetch("https://api.example.com/rates?base=USD");
    if (!response.ok) {
      throw new Error(`Rates API returned ${response.status}`);
    }

    rates = await response.json() as Rates;

    // Values are strings or binary data, so encode objects yourself.
    await req.kv.put("rates:usd", JSON.stringify(rates), {
      expirationTtl: 600,
    });
  }

  // Return the value we already have; an immediate reread may not see the write yet.
  return res.json(rates);
};
src/routes/rates.js
export const GET = async (req, res) => {
  let rates = await req.kv.get("rates:usd", { type: "json" });

  if (rates === null) {
    // Example API; replace with the service you use.
    const response = await fetch("https://api.example.com/rates?base=USD");
    if (!response.ok) {
      throw new Error(`Rates API returned ${response.status}`);
    }

    rates = await response.json();

    // Values are strings or binary data, so encode objects yourself.
    await req.kv.put("rates:usd", JSON.stringify(rates), {
      expirationTtl: 600,
    });
  }

  // Return the value we already have; an immediate reread may not see the write yet.
  return res.json(rates);
};
MethodWhat it does
get(key, options?)Returns the value as text, or null when missing. Pass { type: "json" } to parse JSON.
put(key, value, options?)Stores a value. expirationTtl sets a lifetime in seconds while expiration sets an absolute Unix time in seconds.
delete(key)Removes a key. Other readers may still see it briefly.
list(options?)Returns one page of keys, filtered by prefix.
  • Other read types: type also accepts "arrayBuffer" and "stream", as in Cloudflare's KV API. The string shorthand get(key, "json") is not supported.
  • Await writes: Await put() and delete() before reporting success. Errors are thrown to your handler like any other failure.
  • More options: req.kv also accepts Cloudflare's cacheTtl and metadata options. See Cloudflare's KV docs for details and limits, and use the Cloudflare binding for anything else.

List keys

list() returns keys, list_complete, and a cursor when there are more pages. Pass the cursor back with the same prefix to get the next page:

src/routes/cache-keys.ts
import type { Handler } from "goribu/server";

export const GET: Handler = async (req, res) => {
  const cursor = req.query.get("cursor") || undefined;
  const page = await req.kv.list({ prefix: "public:", limit: 100, cursor });

  return res.json(page);
};
src/routes/cache-keys.js
export const GET = async (req, res) => {
  const cursor = req.query.get("cursor") || undefined;
  const page = await req.kv.list({ prefix: "public:", limit: 100, cursor });

  return res.json(page);
};
  • Stop condition: Keep requesting pages until list_complete is true. Goribu does not paginate for you.
  • Keys are visible: Returning a list shows every key name under that prefix, and names often contain data, such as session:alice@example.com or invite:7f3a…. Only return lists from a public prefix, or check access first.

Use the Cloudflare binding

req.kv covers everyday reads and writes. For anything it doesn't support, such as reading several keys in one call, use the plain Cloudflare binding at req.env.KV:

Inside a request handler
const rates = await req.env.KV.get(["rates:usd", "rates:eur"], "json");
Inside a request handler
const rates = await req.env.KV.get(["rates:usd", "rates:eur"], "json");
  • Same namespace: req.env.KV reads and writes the same data as req.kv, with Cloudflare's full KV API.
  • Same freshness: It is not more up to date than req.kv; reads can still be briefly stale.
  • No Goribu checks: Options are not validated, and if kv is missing from your config, req.env.KV is simply undefined.