Build & Deploy Fullstack Edge API with Wrangler, D1, and KV
Step-by-step guide to developing, caching, and deploying serverless APIs using Cloudflare Workers, Wrangler CLI, D1 SQL Database, and KV Cache.
Building a modern, globally distributed API used to mean managing multiple database replicas, configuring reverse proxies, and provisioning containers across different cloud regions.
With Cloudflare’s Edge Platform and the Wrangler CLI, you can build, test, and deploy a blazingly fast fullstack REST API with a relational database (D1) and low-latency cache (KV) in minutes — running across 300+ data centers worldwide with zero cold start.
1. What Are We Building? (ELI5)#
Think of your application like a high-end restaurant:
- Cloudflare Worker (The Waiter/Chef): Takes incoming requests, executes business logic, and prepares responses right next to the customer.
- Cloudflare KV (The Ready-to-Serve Counter / Cache): Holds pre-made, popular dishes. When someone orders a popular item, the waiter grabs it instantly without going into the kitchen (ultra-fast reads, sub-millisecond response).
- Cloudflare D1 (The Main Pantry & Recipe Vault / Database): A serverless SQLite database where all records are persistently stored and safely modified using SQL queries.
graph TD
Client["📱 Client Request"] -->|HTTP GET/POST| Worker["⚡ Cloudflare Worker (Edge API)"]
Worker -->|1. Check Cache| KV["⚡ Cloudflare KV (Fast Read Cache)"]
KV -.->|Cache Hit - Fast Return| Worker
KV -.->|Cache Miss| D1
Worker -->|2. Query/Mutate Data| D1["🗄️ Cloudflare D1 (Serverless SQLite)"]
D1 -->|3. Populate Cache| KV
Worker -->|HTTP JSON Response| Client
2. Prerequisites & Project Setup#
Ensure you have Node.js ↗ / Bun ↗ installed and an active Cloudflare account.
Step 1: Initialize the Worker Project#
Create a new Cloudflare Workers project using npm create cloudflare@latest (C3) or initialize directly with Wrangler:
# Initialize a TypeScript Worker
npm create cloudflare@latest edge-api -- --type=hello-world-typescript --ts --git --deploy=false
cd edge-apibashInstall the latest Wrangler CLI locally if not already present:
npm install -D wranglerbashAuthenticate Wrangler with your Cloudflare account:
npx wrangler loginbash3. Provisioning Cloudflare D1 (SQL Database)#
Cloudflare D1 is a serverless relational database built on SQLite.
Step 1: Create the D1 Database#
Run the following command to create a D1 database named ecommerce-db:
npx wrangler d1 create ecommerce-dbbashWrangler will output configuration information similar to:
[[d1_databases]]
binding = "DB"
database_name = "ecommerce-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"textStep 2: Define Schema & Apply Migrations#
Create a schema.sql file in your project:
DROP TABLE IF EXISTS products;
CREATE TABLE products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO products (id, name, price) VALUES
('prod_1', 'Wireless Mechanical Keyboard', 129.99),
('prod_2', 'Ultra-Wide Gaming Monitor', 499.99),
('prod_3', 'Ergonomic Standing Desk', 349.50);sqlExecute the schema locally for development:
npx wrangler d1 execute ecommerce-db --local --file=./schema.sqlbashExecute the schema on the remote production database:
npx wrangler d1 execute ecommerce-db --remote --file=./schema.sqlbash4. Provisioning Cloudflare KV (Key-Value Cache)#
KV provides high-throughput, low-latency key-value storage optimized for high-volume read scenarios.
Step 1: Create KV Namespaces#
Create two namespaces: one for production and one for local testing.
# Create production namespace
npx wrangler kv namespace create CACHE_KV
# (Optional) Create preview/test namespace
npx wrangler kv namespace create CACHE_KV --previewbashWrangler will output the namespace IDs:
[[kv_namespaces]]
binding = "CACHE_KV"
id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
preview_id = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"text5. Configuring wrangler.toml#
Update your wrangler.toml (or wrangler.json) file to bind D1 and KV to your Worker environment:
name = "edge-api"
main = "src/index.ts"
compatibility_date = "2026-08-01"
# Bind D1 Database
[[d1_databases]]
binding = "DB"
database_name = "ecommerce-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Bind KV Cache
[[kv_namespaces]]
binding = "CACHE_KV"
id = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
preview_id = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"tomlGenerate TypeScript types for your environment bindings automatically:
npx wrangler typesbash6. Implementing the API with Cache-Aside Pattern#
Now let’s write our API logic in src/index.ts. We implement the Cache-Aside strategy:
- Check KV cache for existing product data.
- If cache hit, return immediately with
CF-Cache-Status: HIT. - If cache miss, query D1 SQLite, store the result in KV with a TTL (Time-To-Live), and return.
- On
POST/PUT, write to D1 and invalidate/update the KV cache key.
export interface Env {
DB: D1Database;
CACHE_KV: KVNamespace;
}
interface Product {
id: string;
name: string;
price: number;
created_at?: string;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Route: GET /api/products/:id
if (method === 'GET' && path.startsWith('/api/products/')) {
const id = path.replace('/api/products/', '');
const cacheKey = `product:${id}`;
// 1. Try reading from KV Cache
const cached = await env.CACHE_KV.get(cacheKey, 'json');
if (cached) {
return Response.json(
{ source: 'kv-cache', data: cached },
{ headers: { 'X-Cache-Status': 'HIT', 'Content-Type': 'application/json' } }
);
}
// 2. Cache Miss: Query D1 Database
const product = await env.DB.prepare('SELECT * FROM products WHERE id = ?')
.bind(id)
.first<Product>();
if (!product) {
return Response.json({ error: 'Product not found' }, { status: 404 });
}
// 3. Store in KV with a 60-second expiration TTL
ctx.waitUntil(
env.CACHE_KV.put(cacheKey, JSON.stringify(product), {
expirationTtl: 60,
})
);
return Response.json(
{ source: 'd1-database', data: product },
{ headers: { 'X-Cache-Status': 'MISS', 'Content-Type': 'application/json' } }
);
}
// Route: POST /api/products
if (method === 'POST' && path === '/api/products') {
const body = (await request.json()) as Partial<Product>;
if (!body.name || !body.price) {
return Response.json({ error: 'Missing name or price' }, { status: 400 });
}
const id = `prod_${Date.now()}`;
await env.DB.prepare('INSERT INTO products (id, name, price) VALUES (?, ?, ?)')
.bind(id, body.name, body.price)
.run();
const newProduct: Product = { id, name: body.name, price: body.price };
// Invalidate or pre-populate cache
await env.CACHE_KV.put(`product:${id}`, JSON.stringify(newProduct), {
expirationTtl: 300,
});
return Response.json({ success: true, product: newProduct }, { status: 201 });
}
return new Response('Edge API is up and running!', { status: 200 });
},
};typescript[!TIP] Use
ctx.waitUntil(...)when writing to KV on cache misses so your API response is returned immediately to the client without waiting for the background cache write to finish.
7. Local Testing and Production Deployment#
Local Development#
Run the local development server with emulated D1 and KV instances:
npx wrangler devbashTest querying products via curl:
# First request (MISS from D1)
curl http://localhost:8787/api/products/prod_1
# Second request (HIT from KV)
curl http://localhost:8787/api/products/prod_1bashProduction Deployment#
Deploy your code to Cloudflare’s global edge network in one single command:
npx wrangler deploybashYou will get an edge URL like https://edge-api.<your-subdomain>.workers.dev live immediately across 300+ edge locations!
8. What If You Use Rust with Axum?#
If you prefer Rust over TypeScript, you can compile Rust to WebAssembly (wasm32-unknown-unknown) and run Axum ↗ / worker-rs ↗ on Cloudflare Workers.
Architectural Differences: TypeScript vs Rust (Axum)#
graph LR
subgraph TS["TypeScript Worker"]
direction TB
TS_Code["src/index.ts"] --> V8["V8 Isolate Engine<br/>(Native JS bindings)"]
V8 --> DB_TS["env.DB / env.CACHE_KV"]
end
subgraph RS["Rust + Axum Worker"]
direction TB
RS_Code["src/lib.rs (Axum Router)"] --> WASM["WASM Module<br/>(wasm32-unknown-unknown)"]
WASM --> WRS["worker-rs FFI Bridge"]
WRS --> DB_RS["env.d1('DB') / env.kv('CACHE_KV')"]
end
| Dimension | TypeScript | Rust (Axum + worker-rs) |
|---|---|---|
| Compilation Target | JavaScript (V8 Isolate) | WebAssembly (.wasm) via worker-build |
| Routing Model | Standard fetch(request, env, ctx) handler | axum::Router with extractors (State, Path, Json) |
| Binding Access | Native object: env.DB, env.CACHE_KV | worker::Env / FFI: env.d1("DB")?, env.kv("CACHE_KV")? |
| Type Safety & Performance | Fast iteration, type checks at build time | Memory safety, zero-cost abstractions, compiled WASM binary |
| wrangler.toml Config | main = "src/index.ts" | main = "build/worker/shim.mjs" + [build] command = "cargo install -q worker-build && worker-build --release" |
Rust Axum Code Example with D1 and KV#
In Rust, we initialize an axum::Router inside the worker entrypoint, sharing worker::Env via Axum’s Extension or State:
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tower_service::Service;
use worker::*;
#[derive(Clone)]
struct AppState {
env: Arc<worker::Env>,
}
#[derive(Serialize, Deserialize)]
struct Product {
id: String,
name: String,
price: f64,
}
// GET /api/products/:id with KV Cache-Aside & D1 fallback
async fn get_product(
Path(id): Path<String>,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let kv = state.env.kv("CACHE_KV").map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let cache_key = format!("product:{}", id);
// 1. Check KV Cache
if let Ok(Some(cached_json)) = kv.get(&cache_key).text().await {
return Ok((
StatusCode::OK,
[("X-Cache-Status", "HIT"), ("Content-Type", "application/json")],
cached_json,
));
}
// 2. Cache Miss: Query D1 Database
let d1 = state.env.d1("DB").map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let statement = d1.prepare("SELECT id, name, price FROM products WHERE id = ?1").bind(&[&id.into()])
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let product = statement.first::<Product>(None).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Product not found".to_string()))?;
let json_str = serde_json::to_string(&product)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 3. Populate KV Cache with 60s TTL
let _ = kv.put(&cache_key, &json_str).unwrap().expiration_ttl(60).execute().await;
Ok((
StatusCode::OK,
[("X-Cache-Status", "MISS"), ("Content-Type", "application/json")],
json_str,
))
}
#[event(fetch)]
async fn fetch(req: HttpRequest, env: Env, _ctx: Context) -> Result<axum::http::Response<axum::body::Body>> {
let state = AppState { env: Arc::new(env) };
let mut router = Router::new()
.route("/api/products/:id", get(get_product))
.with_state(state);
let response = router.call(req).await.unwrap();
Ok(response)
}rust9. Summary of Wrangler CLI Commands#
| Command | Purpose |
|---|---|
wrangler login | Authenticate CLI with your Cloudflare account |
wrangler dev | Start local emulation server with D1 & KV |
wrangler d1 create <name> | Create a new D1 SQL database |
wrangler d1 execute <name> --file=./schema.sql | Execute SQL migrations (--local or --remote) |
wrangler kv namespace create <name> | Create a new KV Key-Value store |
wrangler types | Generate TypeScript definitions for bindings |
wrangler deploy | Build and deploy Worker to production |
wrangler tail | Stream real-time production logs directly in terminal |