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

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 main environment and a staging environment 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

d1_databases

binding, database_name, database_id (assigned on deploy), migrations_dir

Key-value store

kv_namespaces

binding, id (assigned on deploy)

Object storage

r2_buckets

binding, bucket_name (the bucket_id is assigned on deploy)

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"
    }
  ]
}

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);
}

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);
};

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

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.sql and 002_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);

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

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" }
  ]
}

The binding exposes four methods:

Method Purpose Returns

list(options?)

List keys, optionally filtered by a prefix

{ keys, list_complete, cursor }

put(key, value, options?)

Create or update a key-value pair

Promise<void>

get(key, type?, options?)

Read one key, or up to 100 keys at once

The value or null; a Map for an array of keys

delete(key)

Delete one key

Promise<void>

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"),
]);

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"
    }
  ]
}

Methods

Method Behavior

put(key, value, options?)

Uploads an object and returns an R2Object, or null if a precondition in options fails. httpMetadata sets the content type.

get(key, options?)

Returns an R2ObjectBody with the data and metadata, or null if the key does not exist.

delete(key)

Removes one key or an array of up to 1000 keys. Deleting a missing key still succeeds.

list(options?)

Returns objects, truncated and cursor; options include prefix, limit, cursor and delimiter.

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 });
};

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));

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

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 LIKE or GLOB pattern; 30 seconds per query; 5 GB per file import

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

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_id or bucket_name left in wrangler.json after 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 db and then queries DB, an Astro sample that omits const before { env }, and a Next.js upload sample that uses key without 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.