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:
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 devuses 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 kvdeletes 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.cacheneeds 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:
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);
};
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);
};
| Method | What 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:
typealso accepts"arrayBuffer"and"stream", as in Cloudflare's KV API. The string shorthandget(key, "json")is not supported. - Await writes: Await
put()anddelete()before reporting success. Errors are thrown to your handler like any other failure. - More options:
req.kvalso accepts Cloudflare'scacheTtlandmetadataoptions. 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:
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);
};
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_completeistrue. 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.comorinvite: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:
const rates = await req.env.KV.get(["rates:usd", "rates:eur"], "json");
const rates = await req.env.KV.get(["rates:usd", "rates:eur"], "json");
- Same namespace:
req.env.KVreads and writes the same data asreq.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
kvis missing from your config,req.env.KVis simplyundefined.