Webflow Cloud Storage: SQLite, Key-Value and Object Storage
|
This content was generated with the assistance of AI and should be verified against the official Webflow documentation before being relied on in production. This sub-section’s Webflow bibliography lists the reference material consulted while preparing these pages. |
This page describes the storage options of Webflow Cloud as they were on 2026-10-28, the date on which its facts were
checked against the storage overview, SQLite, key-value store and object storage pages, the two quickstarts and the
limits page of the Webflow Cloud documentation on developers.webflow.com. Webflow Cloud itself is covered in
Webflow Cloud, and the deploy side (environments, variables, logs, rollbacks) in
Webflow Cloud: Deployments and Environments.
Storage is available to any app that runs on Webflow Cloud; the amount of storage depends on the Site plan the app is
attached to, and the plan tiers are named in the limits section below, never by price. Webflow Cloud apps can be
created without a paid plan only within the limits of the free tiers listed there. The pinned tooling for the examples
is Node.js 22 or later, @opennextjs/cloudflare 1.20.14, wrangler 4.155.0, drizzle-orm 0.45.4 and drizzle-kit
0.31.11, with the Webflow CLI (@webflow/webflow-cli 2.15.0) described in Webflow CLI.
The running example is Fernbrook Gardens, the fictitious botanical garden. Its developer has built Visit Planner, a Next.js app on Webflow Cloud mounted under the garden’s site, in which visitors choose plants and zones they want to see and get a printable itinerary. The planner needs three kinds of persistence: structured itineraries that belong to a visitor (SQLite), short-lived session and cache data (key-value store), and uploaded files such as a visitor’s own route map or a generated PDF itinerary (object storage). Plants and events themselves stay in the Webflow CMS and are read through the Data API or the Content Delivery API; see Content Delivery API and Headless Front Ends.
Why a Cloud app needs a storage layer
A Webflow Cloud app runs on Cloudflare Workers, a V8-isolate runtime with no writable local file system and no long-lived process. Anything an app keeps in a module-level variable or writes to disk disappears between requests, and two requests can be served by different isolates in different locations. An app that must remember something therefore needs a storage service reached over the network.
Webflow Cloud offers three, each backed by a Cloudflare product and each connected to the app by a binding:
| Storage type | Cloudflare product | Best for | Fernbrook Gardens use |
|---|---|---|---|
SQLite |
D1 |
Structured, relational data such as profiles, catalogs and transactional records |
Itineraries, itinerary stops, saved visits |
Key-value store |
Workers KV |
Unstructured, dynamic data such as preferences, session data and temporary settings |
Visitor sessions, cached opening-hours lookup, rate-limit counters |
Object storage |
R2 |
Large files and unstructured data such as images, videos and PDFs |
Generated PDF itineraries, visitor-uploaded route maps |
Source: Webflow Cloud: Storage options
The choice is about the shape of the data and the consistency it needs, not about convenience. If a value must be queried by more than its key, joined with another table or updated transactionally, it belongs in SQLite. If it is read far more often than it is written, can tolerate being slightly stale and is looked up by one key, it belongs in the key-value store. If it is a blob that is written and read whole, it belongs in object storage.
Bindings: how an app reaches storage
A binding is a named connection between the app’s code and a storage resource. It grants access without any secret
key, which means there is no connection string, token or password to store in an environment variable and nothing that
can leak from a repository or a log. The binding is injected into the app at runtime, and its name appears on the
runtime env object.
Three facts shape everything else on this page:
-
Bindings are declared in
wrangler.json, a file committed to the app’s repository. On deploy, Webflow Cloud reads the declaration and creates the resource if it does not exist. -
Bindings are specific to each environment. The
mainenvironment and astagingenvironment of the same app have separate databases, namespaces and buckets, so test data never mixes with production data. -
Binding names become properties of
env; the names are chosen by the developer and must be valid JavaScript identifiers.
Each storage type has its own array in wrangler.json:
| Storage type | Array | Fields |
|---|---|---|
SQLite |
|
|
Key-value store |
|
|
Object storage |
|
|
Source: Webflow Cloud: Storage options
The Visit Planner declares one binding of each type in a single wrangler.json:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "fernbrook_planner",
"database_id": "<assigned-on-deploy>",
"migrations_dir": "drizzle"
}
],
"kv_namespaces": [
{ "binding": "SESSIONS_KV" }
],
"r2_buckets": [
{
"binding": "ITINERARY_FILES",
"bucket_name": "fernbrook-itinerary-files"
}
]
}
Source: Webflow Cloud: Storage options
You can also create a binding without typing the JSON: open the environment’s Storage tab in the Webflow Cloud
dashboard, choose Add Storage, and copy the generated snippet into wrangler.json. Once the app is deployed, the
same tab lists every binding with its name, type and creation date, and selecting a binding opens a database viewer
where records can be created, read, updated and deleted, and searched and sorted. The viewer is useful to inspect the
state of a staging environment, but production data should be changed through the app or a migration, not by hand.
If a binding does not appear in the dashboard for an app that already existed, open the app’s Actions menu, choose Edit, select Save Changes and redeploy. Apps created before mid-July 2025 need that step before the key-value store can be used.
Starter apps for each framework (hello-world-next-app and hello-world-astro-app) already include bindings for D1,
R2 and two KV namespaces, plus a /api/binding-status health-check route that confirms the bindings work. Starting
from one of them is the quickest way to get a working wrangler.json.
Reading a binding in code
Binding names are exposed as environment variables on the runtime env object, so no manual creation is needed. How
the app obtains env depends on the framework.
Next.js
Next.js apps on Webflow Cloud run through the OpenNext adapter for Cloudflare. A binding is read with
getCloudflareContext() from @opennextjs/cloudflare:
import { getCloudflareContext } from "@opennextjs/cloudflare";
export async function GET() {
const { env } = getCloudflareContext();
const row = await env.DB.prepare("SELECT 1 AS ok").first();
return Response.json(row);
}
Source: Webflow Cloud: Storage options
Two rules apply:
-
Call
getCloudflareContext()inside a function, such as a route handler or a server component, never at the top level of a module. At module load time there is no request, and the call fails. -
For static routes (ISR or SSG) and for code that runs outside a request handler, use the asynchronous form,
await getCloudflareContext({ async: true }).
Astro
Astro apps read the binding from locals.runtime.env, where locals is the object an API route or an .astro page
receives (Astro.locals inside a page):
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ locals }) => {
const env = (locals as any).runtime.env;
const row = await env.DB.prepare("SELECT 1 AS ok").first();
return Response.json(row);
};
Source: Webflow Cloud: SQLite
Generated types
Without types, env.DB is any. Generating types gives editor autocomplete and compile-time checks. Both frameworks
write the result to worker-configuration.d.ts:
# Astro
npx wrangler types
# Next.js
npx wrangler types
npm run cf-typegen
npx wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts
Source: Webflow Cloud: Key Value Store
For Next.js, the documentation also asks you to update the types in cloudflare-env.d.ts; otherwise the compiler
reports errors on the binding names. Re-run type generation after every change to wrangler.json.
For the individual types the pages cast the binding, for example env.DB as D1Database using the
@cloudflare/workers-types package, which keeps the code correct even before generated types are in place.
SQLite (D1)
SQLite storage is a D1 database: a SQLite-compatible relational database reached through a binding. It is the right choice whenever data has relations or needs to be queried in several ways.
Declaring the database and writing migrations
The d1_databases entry carries a migrations_dir, a folder of versioned .sql files that Webflow Cloud applies
automatically on each deployment. The database_id is assigned on the first deploy, so the value in the file is a
placeholder until then.
The rules for migrations are strict because they are additive only:
-
Use sequential, ordered file names such as
001_init.sqland002_add_visit_date_index.sql. -
Never edit or delete a migration after it has been deployed. Editing or deleting a file does not undo what it already did; a schema change is a new file.
-
Test every migration locally before deploying.
The first migration of the planner, migrations/001_init.sql, creates the itinerary tables:
CREATE TABLE IF NOT EXISTS itineraries (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
visitor_ref TEXT NOT NULL,
visit_date TEXT NOT NULL,
locale TEXT NOT NULL DEFAULT 'en',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS itinerary_stops (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
itinerary_id INTEGER NOT NULL REFERENCES itineraries(id),
plant_slug TEXT NOT NULL,
zone_slug TEXT NOT NULL,
position INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_stops_itinerary ON itinerary_stops(itinerary_id);
Source: Webflow Cloud: SQLite
The tables store only the CMS slugs of plants and zones, not copies of their content. The planner resolves the slugs
against the CMS when it renders an itinerary, so the garden’s editors remain the single source of truth for plant
names and care notes. The locale column records whether the visitor used the English, Spanish or German version of the
site, so the itinerary can be rendered in the same language.
Using Drizzle ORM
Writing SQL strings in route handlers is fine for a few queries; for a schema that grows, an ORM that generates the
migrations is easier to maintain. The documentation supports Drizzle ORM (and mentions Prisma for the edge). With
Drizzle, the schema and the generated migration files live in a dedicated folder, and migrations_dir points at it,
as in the migrations_dir setting of the wrangler.json above, which is drizzle.
Install the packages:
npm i drizzle-orm@0.45.4
npm i -D drizzle-kit@0.31.11 tsx better-sqlite3
Create drizzle.config.ts in the app root:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema/index.ts",
out: "./drizzle",
dialect: "sqlite",
});
Add scripts to package.json so the migration steps are repeatable:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:apply:local": "wrangler d1 migrations apply DB --local"
}
}
Define the planner’s tables in src/db/schema/index.ts:
import { sqliteTable, text, int } from "drizzle-orm/sqlite-core";
export const itineraries = sqliteTable("itineraries", {
id: int().primaryKey({ autoIncrement: true }),
visitorRef: text("visitor_ref").notNull(),
visitDate: text("visit_date").notNull(),
locale: text().notNull().default("en"),
});
export const itineraryStops = sqliteTable("itinerary_stops", {
id: int().primaryKey({ autoIncrement: true }),
itineraryId: int("itinerary_id")
.notNull()
.references(() => itineraries.id),
plantSlug: text("plant_slug").notNull(),
zoneSlug: text("zone_slug").notNull(),
position: int().notNull(),
});
Then generate and apply the migration, and repeat both whenever the schema changes:
npm run db:generate
npm run db:apply:local
A database helper for each framework
A small helper keeps the binding lookup in one place. In Next.js the helper wraps getCloudflareContext() and is
cached per request; the asynchronous variant serves static routes:
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { drizzle } from "drizzle-orm/d1";
import { cache } from "react";
import * as schema from "./schema";
export const getDb = cache(() => {
const { env } = getCloudflareContext();
return drizzle(env.DB, { schema });
});
export const getDbAsync = cache(async () => {
const { env } = await getCloudflareContext({ async: true });
return drizzle(env.DB, { schema });
});
In Astro the helper takes locals:
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export const getDb = (locals: App.Locals) => {
const { env } = locals.runtime;
return drizzle(env.DB, { schema });
};
Itinerary routes
The Visit Planner needs a route that saves an itinerary with its stops and one that reads it back. In Next.js, in
src/app/api/itineraries/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getDb } from "@/db/getDb";
import { itineraries, itineraryStops } from "@/db/schema";
type StopInput = { plantSlug: string; zoneSlug: string };
type ItineraryInput = {
visitorRef: string;
visitDate: string;
locale: "en" | "es" | "de";
stops: StopInput[];
};
export async function POST(request: NextRequest) {
const db = getDb();
const body: ItineraryInput = await request.json();
try {
const [created] = await db
.insert(itineraries)
.values({
visitorRef: body.visitorRef,
visitDate: body.visitDate,
locale: body.locale,
})
.returning();
if (body.stops.length > 0) {
await db.insert(itineraryStops).values(
body.stops.map((stop, index) => ({
itineraryId: created.id,
plantSlug: stop.plantSlug,
zoneSlug: stop.zoneSlug,
position: index,
})),
);
}
return NextResponse.json(created, { status: 201 });
} catch (error) {
return NextResponse.json({ error: "Failed to save itinerary" }, { status: 500 });
}
}
The route follows the pattern of the quickstart’s user routes: a POST that inserts and returns the created row, and a
GET that selects. Validate the body before inserting in a real app, and remember that an itinerary route is public
unless the app adds its own authentication, because a Cloud route is reachable by anyone who knows the URL.
Local development
Local runs never touch remote data. They create a SQLite database in the .wrangler folder, so a local experiment
cannot damage staging or production. The commands are:
# run the app locally
npx wrangler dev # Astro
npx opennextjs-cloudflare preview # Next.js
# apply migrations to the local database
wrangler d1 migrations apply DB --local
# run a statement or a file against the local database
wrangler d1 execute DB --local --command "SELECT COUNT(*) FROM itineraries"
wrangler d1 execute DB --local --file ./migrations/001_init.sql
Source: Webflow Cloud: SQLite
The DB argument identifies the database by its binding name from wrangler.json; use the binding name of your own
app.
Design notes
-
A database can hold up to 10 GB, and an app can have an unlimited number of databases; multiple databases carry no extra cost, because pricing depends on query and storage use. Splitting data into many small databases, for example one per tenant, can improve isolation and query efficiency.
-
The SQL side is SQLite: foreign keys, indexes and transactions work as in SQLite, with the D1 limits listed below.
-
Plain deploys apply new migrations, but they never roll data back. A rollback of the code by redeploying an older commit leaves the database at the newest schema, so write migrations that the previous release can still read.
Key-value store (Workers KV)
The key-value store maps a string key to a value and is built for reads. It is the right tool for a cache or for session data with an expiry, and the wrong tool for anything that must be consistent immediately everywhere.
Declaring and using a namespace
Add a kv_namespaces entry with a binding name. The id is optional in the file because Webflow Cloud creates the
namespace and assigns its identifier on deploy:
{
"kv_namespaces": [
{ "binding": "SESSIONS_KV" }
]
}
Source: Webflow Cloud: Key Value Store
The binding exposes four methods:
| Method | Purpose | Returns |
|---|---|---|
|
List keys, optionally filtered by a prefix |
|
|
Create or update a key-value pair |
|
|
Read one key, or up to 100 keys at once |
The value or |
|
Delete one key |
|
Source: Webflow Cloud: Key Value Store
The planner keeps a visitor’s draft itinerary in KV while the visitor is still choosing plants, and writes it to SQLite only when the visitor saves. A draft expires after two hours without a write, so abandoned drafts clean themselves up:
import { getCloudflareContext } from "@opennextjs/cloudflare";
import type { KVNamespace } from "@cloudflare/workers-types";
type Draft = { visitDate: string; stops: { plantSlug: string; zoneSlug: string }[] };
export async function PUT(request: Request) {
const { env } = getCloudflareContext();
const kv = env.SESSIONS_KV as KVNamespace;
const sessionId = new URL(request.url).searchParams.get("session");
if (!sessionId) {
return Response.json({ error: "session is required" }, { status: 400 });
}
const draft: Draft = await request.json();
await kv.put(`draft:${sessionId}`, JSON.stringify(draft), {
expirationTtl: 7200,
});
return new Response(null, { status: 204 });
}
export async function GET(request: Request) {
const { env } = getCloudflareContext();
const kv = env.SESSIONS_KV as KVNamespace;
const sessionId = new URL(request.url).searchParams.get("session");
const stored = sessionId ? await kv.get(`draft:${sessionId}`) : null;
return stored
? new Response(stored, { headers: { "Content-Type": "application/json" } })
: Response.json({ error: "Not found" }, { status: 404 });
}
The expirationTtl option comes from the quickstart, which uses it to cache a weather lookup for 600 seconds. The
minimum cache TTL is 60 seconds.
Listing, batching and deleting
There is no bulk put, and delete takes one key per call, so batches use Promise.all. Prefixes make groups of keys
easy to list:
// list the keys of one visitor's saved drafts
const { keys } = await env.SESSIONS_KV.list({ prefix: "draft:" });
const draftKeys = keys.map((k) => k.name);
// write several pairs
await Promise.all([
env.SESSIONS_KV.put("opening:mon", "closed"),
env.SESSIONS_KV.put("opening:tue", "09:00-17:00"),
]);
// read up to 100 keys in one call: the result is a Map, missing keys are null
const hours = await env.SESSIONS_KV.get(["opening:mon", "opening:tue"]);
// delete keys one by one; deleting a missing key still succeeds
await Promise.all([
env.SESSIONS_KV.delete("opening:mon"),
env.SESSIONS_KV.delete("opening:tue"),
]);
Source: Webflow Cloud: Key Value Store
Consistency: design for stale reads
Workers KV is eventually consistent:
-
A change is visible immediately in the same region but can take up to 60 seconds to reach other regions, so a read from another region can return an older value.
-
When two writers update the same key, the last write wins.
-
The same key can be written at most once per second.
-
KV is not suitable for transactions, for strong consistency or for advanced queries. Use SQLite for those.
For time-sensitive values, store a version or a timestamp inside the value so the reader can tell a stale copy from a
fresh one. For the planner, a draft carries an updatedAt field, and a client that reads a draft older than its own
last write ignores it.
Object storage (R2)
Object storage keeps whole files, called objects, identified by a unique key inside a bucket. It has no egress fees, an S3-compatible API and operations that follow the Cloudflare R2 Workers API.
Declaring a bucket
Add an r2_buckets entry. The binding name must be a valid JavaScript variable name, and the bucket_name placeholder
is replaced after the first deployment:
{
"r2_buckets": [
{
"binding": "ITINERARY_FILES",
"bucket_name": "fernbrook-itinerary-files"
}
]
}
Source: Webflow Cloud: Object Storage
Methods
| Method | Behavior |
|---|---|
|
Uploads an object and returns an |
|
Returns an |
|
Removes one key or an array of up to 1000 keys. Deleting a missing key still succeeds. |
|
Returns |
Source: Webflow Cloud: Object Storage
Writes are strongly consistent: once put() resolves, every later read, anywhere, sees the new object. That is the
opposite of the key-value store and is why files belong here.
Storing and serving a PDF itinerary
The planner generates a PDF for a saved itinerary and stores it under a key made from the itinerary id. An Astro route that stores an uploaded route map looks like this:
import type { APIRoute } from "astro";
import type { R2Bucket } from "@cloudflare/workers-types";
export const PUT: APIRoute = async ({ request, locals, params }) => {
const env = (locals as any).runtime.env;
const bucket = env.ITINERARY_FILES as R2Bucket;
const key = `maps/${params.id}.png`;
await bucket.put(key, request.body, {
httpMetadata: { contentType: "image/png" },
});
return new Response(null, { status: 201 });
};
export const GET: APIRoute = async ({ locals, params }) => {
const env = (locals as any).runtime.env;
const bucket = env.ITINERARY_FILES as R2Bucket;
const object = await bucket.get(`maps/${params.id}.png`);
return object
? new Response(object.body, { headers: { "Content-Type": "image/png" } })
: new Response("Not found", { status: 404 });
};
Source: Webflow Cloud: Object Storage
In Next.js the same code reads the binding with getCloudflareContext() and takes the key from the request URL or the
dynamic route segment. The documentation’s own upload example reads the key from the request URL; define the key
explicitly in your handler, as above.
Listing a visitor’s files and deleting old ones:
const { objects, truncated, cursor } = await bucket.list({
prefix: "maps/",
limit: 100,
});
const keys = objects.map((obj) => obj.key);
await bucket.delete(keys.slice(0, 1000));
Source: Webflow Cloud: Object Storage
Buckets are private
Public buckets are not supported in Webflow Cloud. A file stored in a bucket can only be read through code in the app,
which means access control is whatever the route does. To serve a file to everyone, such as the garden’s printable
site map, put it in the app’s public folder instead of a bucket. Keep itinerary PDFs private by serving them only
from a route that checks the visitor reference before returning the object.
Choosing the right store for Visit Planner
| Data | Store | Reason |
|---|---|---|
Saved itineraries and stops |
SQLite |
Relational, queried by visitor and date, must be consistent |
Visitor draft while choosing |
Key-value store |
Short-lived, read often, expires on its own |
Cached opening hours fetched from the CMS |
Key-value store |
Read-heavy, a stale value for up to a minute is acceptable |
Generated PDF itineraries and uploaded maps |
Object storage |
Whole files, read whole, strongly consistent |
Plant names, care notes, events |
None: the Webflow CMS |
Edited by the garden’s editors and read through the Data or Content Delivery API |
A good rule is that the CMS holds content that people edit, the app’s storage holds data that visitors generate. Copying CMS content into SQLite creates two sources of truth and a synchronisation job nobody wants to own.
Limits by plan
Storage amounts are per site and depend on the Site plan the app belongs to. The limits page of the documentation lists these tiers as of the check date.
| Plan tier | SQLite total | KV total | KV reads per day | KV writes to different keys per day | Object storage total |
|---|---|---|---|---|---|
Free, Starter, Basic |
100 MB |
100 MB |
100,000 |
10,000 |
1 GB |
Ecommerce Standard |
100 MB |
100 MB |
100,000 |
10,000 |
100 MB |
Premium |
200 MB |
200 MB |
200,000 |
20,000 |
2 GB |
Business |
1 GB |
500 MB |
500,000 |
50,000 |
5 GB |
Ecommerce Plus and Advanced |
1 GB |
500 MB |
500,000 |
50,000 |
500 MB |
Team and Enterprise |
Custom |
Custom |
Custom |
Custom |
Custom |
Source: Webflow Cloud: Limits
The limits page still lists the Business tier, which the May 2026 plan change merged into Premium; sites on the old plans keep their old tier until they migrate, as explained in Plans, Workspaces, Seats and Roles.
Per-service limits apply to every plan:
| Service | Limits |
|---|---|
SQLite |
Databases per app unlimited; 1,000 queries per invocation; 100 columns per table; rows per table unlimited within
storage; 2 MB per string, BLOB or table row; 100 KB per SQL statement; 100 bound parameters per query; 32 arguments per
SQL function; 50 bytes per |
Key-value store |
Namespaces unlimited; keys per namespace unlimited within storage; one write per second to the same key; 1,000 operations per invocation; 512 bytes per key; 1,024 bytes of key metadata; 25 MiB per value; 60 seconds minimum cache TTL |
Object storage |
Buckets per app unlimited; 50 management operations per second per bucket; 1,024 bytes per object key; 8,192 bytes of object metadata; 5 TiB per object; 5 GiB per upload (multipart supported); 10,000 upload parts; one concurrent write per second to the same object |
Bindings |
At most 5,000 per app |
Source: Webflow Cloud: Limits
Three limits drive design decisions. The 1,000 queries per invocation cap means a loop that issues one query per row is a bug waiting for a long itinerary; use one multi-row insert, as the route above does. The single write per second to the same key means a counter in KV is not safe under load; keep counters in SQLite. The 2 MB row size means files do not belong in SQLite columns.
Pitfalls and security
-
Calling
getCloudflareContext()at module scope fails in Next.js; call it inside a function, or use the async form for static routes. -
A binding name that is not a valid JavaScript identifier cannot be used on
env. -
A placeholder
database_idorbucket_nameleft inwrangler.jsonafter the first deployment points nowhere; replace it. -
Edited migrations are not re-applied. Fix a mistake with a new migration file.
-
Local commands act on
.wrangler, not on staging or production; use the dashboard viewer to inspect deployed data. -
The documentation’s code samples contain small slips (an Astro sample that declares
dband then queriesDB, an Astro sample that omitsconstbefore{ env }, and a Next.js upload sample that useskeywithout defining it). The examples on this page define every variable. -
Bindings carry no secrets, but the routes that use them are public URLs. Authenticate or validate every request that writes, and never trust a visitor-supplied key as a path: build keys from identifiers the app generated, and reject keys that contain
..or unexpected prefixes. -
Do not store personal data longer than needed. The expiring KV draft and a scheduled cleanup of old itineraries keep visitor data from accumulating.
Corrections to older advice
Older tutorials and books written before Webflow Cloud had storage describe Webflow sites as static: any persistence had to go to a third-party database or to a service reached from client-side script. Webflow Cloud storage, generally available since 2025-11-06, makes server-side persistence a built-in capability with bindings instead of secret keys. The catalogue of such outdated claims is collected in Where Older Books and Tutorials Are Outdated.