Hands-on Workshop
Build a Bookmark API with Cloudflare Workers
Go from zero to a production API with storage, databases, and AI - in seven steps.
- 01 Getting Started 10 min
- 02 Add Routes and CRUD Endpoints 20 min
- 03 Persistent Storage with KV 15 min
- 04 D1 Database with KV Caching 20 min
- 05 AI-Powered Summaries 20 min
- 06 AI Gateway 10 min
- 07 Deploy to Production 5 min
Hands-on Labs
Getting Started
Scaffold the Bookmark API project, understand the Workers runtime, and test your first Worker locally.
Prerequisites
- Node.js v20+
- Cloudflare Account
- Terminal/Command Line
- Basic JavaScript knowledge
Learning Objectives
- Scaffold a Workers project and understand the generated file structure
- Explain how the fetch handler receives and responds to HTTP requests
- Use Wrangler to run, test, and iterate on a Worker locally
Watch: Cloudflare Workers 101
Before diving in, watch this overview of Workers architecture and concepts.
Step 1: Authenticate with Cloudflare
# Log in to your Cloudflare account (opens a browser window)
npx wrangler login
# Verify you are authenticated
npx wrangler whoami
You should see your account name and ID. If not, run npx wrangler login again.
If you skip authentication, commands like npx wrangler kv namespace create, npx wrangler d1 create, and npx wrangler deploy will fail with auth errors in later steps.
Step 2: Create the Bookmark API Project
# Create the project
npm create cloudflare@latest -- bookmark-api --type hello-world --ts
# Navigate into the project
cd bookmark-api
# Start the local dev server
npm run dev
Running npm create cloudflare@latest will trigger a series of interactive prompts (e.g. package manager, git initialization, deploy). Accept the defaults for each prompt to match the configuration used throughout this workshop.
Your project contains:
src/index.ts- The Worker entry point with afetchhandlerwrangler.jsonc- Wrangler CLI configuration (name, entry point, bindings)package.json- Dependencies and scriptstsconfig.json- TypeScript configuration
Troubleshooting
- npm not found: Install Node.js v20+ from nodejs.org
- Template not found: Update npm:
npm install -g npm@latest - Port 8787 busy: Use
npx wrangler dev --port 8788 - Build errors: Delete
node_modulesand runnpm installagain
Step 3: Understand the Code
Open src/index.ts:
export default {
async fetch(request, env, ctx): Promise<Response> {
return new Response('Hello World!');
},
} satisfies ExportedHandler<Env>;
Every Cloudflare Worker exports a default object with handler functions. The fetch handler is called for every HTTP request.
| Parameter | Description |
|---|---|
request | The incoming HTTP request (URL, headers, method, body) |
env | Environment variables and bindings (KV, D1, AI - you will add these in later steps) |
ctx | Context with waitUntil() for background work and passThroughOnException() for fallback |
Workers can also export scheduled (cron triggers), queue (message processing), and email (email routing) handlers. This workshop focuses on fetch.
Step 4: Test and Modify
Test in the browser
- Open
http://localhost:8787. You should see “Hello World!” - Try
http://localhost:8787/anything. Same response (no routing yet)
Make a change
Update src/index.ts to return JSON instead of plain text:
export default {
async fetch(request, env, ctx): Promise<Response> {
return Response.json({
name: 'Bookmark API',
version: '1.0.0',
status: 'running'
});
},
} satisfies ExportedHandler<Env>;
Save the file. Wrangler picks up the change automatically. Refresh your browser to see JSON output.
Response.json() is a convenience method that serializes an object to JSON and sets Content-Type: application/json automatically. You will use it throughout this workshop.
- Add a timestamp field to the JSON response using
new Date().toISOString() - Add a region field that reads
request.cf?.colo(the Cloudflare data center handling the request. This will show a value when deployed, but may be undefined locally) - Return a 404 response for any path other than
/(hint: usenew URL(request.url).pathname)
These are not graded. Experiment freely, then move on to the next step.
Add Routes and CRUD Endpoints
Add HTTP routing to your Bookmark API with endpoints for creating, listing, retrieving, and deleting bookmarks using an in-memory store.
Prerequisites
- Completed Step 1
- bookmark-api project running locally
Learning Objectives
- Design a URL and method-based routing layer without external frameworks
- Parse request bodies, validate input, and return structured error responses
- Implement a full CRUD lifecycle for a REST resource
Step 1: Define the Bookmark Type
Open src/index.ts and replace the entire file with the following. Start by defining the Bookmark interface and an in-memory store:
interface Bookmark {
id: string;
url: string;
title: string;
createdAt: string;
}
// In-memory store (lost on restart, we will fix this in Step 3)
const bookmarks: Map<string, Bookmark> = new Map();
A global Map works for local development, but Workers may run in multiple isolates and memory is not shared between them. In production, data stored this way is unreliable. Step 3 replaces this with Workers KV.
Step 2: Add the Router
Add the fetch handler below the bookmark interface:
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;
// GET /bookmarks - list all
if (path === '/bookmarks' && method === 'GET') {
return listBookmarks();
}
// POST /bookmarks - create
if (path === '/bookmarks' && method === 'POST') {
return createBookmark(request);
}
// Match /bookmarks/:id pattern (used by both GET and DELETE below)
const match = path.match(/^\/bookmarks\/([a-zA-Z0-9_-]+)$/);
// GET /bookmarks/:id - get one
if (match && method === 'GET') {
return getBookmark(match[1]);
}
// DELETE /bookmarks/:id - delete one
if (match && method === 'DELETE') {
return deleteBookmark(match[1]);
}
// Fallback
if (path === '/') {
return Response.json({
name: 'Bookmark API',
endpoints: ['GET /bookmarks', 'POST /bookmarks', 'GET /bookmarks/:id', 'DELETE /bookmarks/:id']
});
}
return Response.json({ error: 'Not Found' }, { status: 404 });
},
};
Step 3: Implement the Handlers
Add these functions below the export default block:
function listBookmarks(): Response {
const all = Array.from(bookmarks.values());
return Response.json({ bookmarks: all, count: all.length });
}
async function createBookmark(request: Request): Promise<Response> {
let body: { url?: string; title?: string };
try {
body = await request.json() as { url?: string; title?: string };
} catch {
return Response.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
);
}
if (!body.url || !body.title) {
return Response.json(
{ error: 'Missing required fields: url, title' },
{ status: 400 }
);
}
const id = crypto.randomUUID().slice(0, 8);
const bookmark: Bookmark = {
id,
url: body.url,
title: body.title,
createdAt: new Date().toISOString(),
};
bookmarks.set(id, bookmark);
return Response.json(bookmark, { status: 201 });
}
function getBookmark(id: string): Response {
const bookmark = bookmarks.get(id);
if (!bookmark) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
return Response.json(bookmark);
}
function deleteBookmark(id: string): Response {
if (!bookmarks.has(id)) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
bookmarks.delete(id);
return Response.json({ message: 'Bookmark deleted' });
}
Workers support the Web Crypto API natively. crypto.randomUUID() generates a UUID v4 string. We slice it to 8 characters for shorter, friendlier IDs.
request.json() throws an error if the request body is empty or contains invalid JSON. The try/catch block returns a clear 400 error instead of crashing the Worker. This is a good habit for any endpoint that reads a request body.
Step 4: Test Your API
Make sure npm run dev is running, then test:
Create bookmarks
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/workers/","title":"Workers Docs"}'
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/d1/","title":"D1 Docs"}'
List all bookmarks
curl -s http://localhost:8787/bookmarks | jq
Get a single bookmark
Use an id from the create response:
curl -s http://localhost:8787/bookmarks/REPLACE_ID | jq
Delete a bookmark
curl -X DELETE http://localhost:8787/bookmarks/REPLACE_ID
Test error cases
Missing fields — should return 400:
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com"}'
Non-existent bookmark — should return 404:
curl http://localhost:8787/bookmarks/does-not-exist
If you restart the dev server, all bookmarks are lost. This is expected. The in-memory store only lives for the duration of the Worker process. In Step 3, you will replace it with Workers KV so bookmarks survive restarts and deployments.
The Complete File
For reference, here is the full src/index.ts after this step:
Full src/index.ts
interface Bookmark {
id: string;
url: string;
title: string;
createdAt: string;
}
const bookmarks: Map<string, Bookmark> = new Map();
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;
if (path === '/bookmarks' && method === 'GET') {
return listBookmarks();
}
if (path === '/bookmarks' && method === 'POST') {
return createBookmark(request);
}
// Match /bookmarks/:id pattern (used by both GET and DELETE below)
const match = path.match(/^\/bookmarks\/([a-zA-Z0-9_-]+)$/);
if (match && method === 'GET') {
return getBookmark(match[1]);
}
if (match && method === 'DELETE') {
return deleteBookmark(match[1]);
}
if (path === '/') {
return Response.json({
name: 'Bookmark API',
endpoints: ['GET /bookmarks', 'POST /bookmarks', 'GET /bookmarks/:id', 'DELETE /bookmarks/:id']
});
}
return Response.json({ error: 'Not Found' }, { status: 404 });
},
};
function listBookmarks(): Response {
const all = Array.from(bookmarks.values());
return Response.json({ bookmarks: all, count: all.length });
}
async function createBookmark(request: Request): Promise<Response> {
let body: { url?: string; title?: string };
try {
body = await request.json() as { url?: string; title?: string };
} catch {
return Response.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
);
}
if (!body.url || !body.title) {
return Response.json(
{ error: 'Missing required fields: url, title' },
{ status: 400 }
);
}
const id = crypto.randomUUID().slice(0, 8);
const bookmark: Bookmark = {
id,
url: body.url,
title: body.title,
createdAt: new Date().toISOString(),
};
bookmarks.set(id, bookmark);
return Response.json(bookmark, { status: 201 });
}
function getBookmark(id: string): Response {
const bookmark = bookmarks.get(id);
if (!bookmark) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
return Response.json(bookmark);
}
function deleteBookmark(id: string): Response {
if (!bookmarks.has(id)) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
bookmarks.delete(id);
return Response.json({ message: 'Bookmark deleted' });
}- Add a
PUT /bookmarks/:idendpoint that updates thetitleorurlof an existing bookmark. Return404if the bookmark does not exist,400if no fields are provided. - Add a
GET /bookmarks?search=termendpoint that filters bookmarks by title (case-insensitive). Hint: useurl.searchParams.get('search').
Persistent Storage with KV
Replace the in-memory bookmark store with Workers KV so bookmarks persist across restarts and deployments.
Prerequisites
- Completed Step 2
- bookmark-api project with CRUD endpoints
Learning Objectives
- Connect an external resource to a Worker using bindings
- Swap an in-memory data layer for persistent KV storage without changing the API contract
- Reason about eventual consistency and when KV is the right storage choice
Your Bookmark API works, but bookmarks are lost whenever the Worker restarts. In this step you will create a Workers KV namespace and replace the in-memory Map with KV operations. The API endpoints and response format stay exactly the same. Only the storage layer changes.
Step 1: Create a KV Namespace
npx wrangler kv namespace create "BOOKMARKS"
When prompted “Would you like Wrangler to add it on your behalf?”, type Y. This adds the KV binding to your wrangler.jsonc.
Then regenerate your types so TypeScript knows about the binding:
npx wrangler types
A binding connects an external resource (KV, D1, R2, AI) to your Worker. After binding, the resource is available on the env object, e.g. env.BOOKMARKS. No API keys or connection strings needed.
Step 2: Update the Handlers to Use KV
Replace the entire src/index.ts with the following. The key changes from Step 2 are marked with comments:
interface Bookmark {
id: string;
url: string;
title: string;
createdAt: string;
}
// REMOVED: const bookmarks: Map<string, Bookmark> = new Map();
// Bookmarks are now stored in KV via env.BOOKMARKS
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;
// CHANGED: all handlers now receive env for KV access
if (path === '/bookmarks' && method === 'GET') {
return listBookmarks(env);
}
if (path === '/bookmarks' && method === 'POST') {
return createBookmark(request, env);
}
const match = path.match(/^\/bookmarks\/([a-zA-Z0-9_-]+)$/);
if (match && method === 'GET') {
return getBookmark(match[1], env);
}
if (match && method === 'DELETE') {
return deleteBookmark(match[1], env);
}
if (path === '/') {
return Response.json({
name: 'Bookmark API',
version: '2.0.0',
storage: 'Workers KV',
endpoints: ['GET /bookmarks', 'POST /bookmarks', 'GET /bookmarks/:id', 'DELETE /bookmarks/:id']
});
}
return Response.json({ error: 'Not Found' }, { status: 404 });
},
};
// CHANGED: list from KV using .list() + individual .get() calls
// NOTE: This fetches each value individually (N+1 pattern). Fine for small
// datasets, but for better performance at scale, store titles as KV metadata
// so you can list without fetching each value (see the challenge below).
async function listBookmarks(env: Env): Promise<Response> {
const keys = await env.BOOKMARKS.list();
const all: Bookmark[] = [];
for (const key of keys.keys) {
const value = await env.BOOKMARKS.get<Bookmark>(key.name, 'json');
if (value) all.push(value);
}
return Response.json({ bookmarks: all, count: all.length });
}
// CHANGED: store in KV with .put()
async function createBookmark(request: Request, env: Env): Promise<Response> {
let body: { url?: string; title?: string };
try {
body = await request.json() as { url?: string; title?: string };
} catch {
return Response.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
);
}
if (!body.url || !body.title) {
return Response.json(
{ error: 'Missing required fields: url, title' },
{ status: 400 }
);
}
const id = crypto.randomUUID().slice(0, 8);
const bookmark: Bookmark = {
id,
url: body.url,
title: body.title,
createdAt: new Date().toISOString(),
};
// Store as JSON in KV, keyed by ID
await env.BOOKMARKS.put(id, JSON.stringify(bookmark));
return Response.json(bookmark, { status: 201 });
}
// CHANGED: retrieve from KV with .get()
async function getBookmark(id: string, env: Env): Promise<Response> {
const bookmark = await env.BOOKMARKS.get<Bookmark>(id, 'json');
if (!bookmark) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
return Response.json(bookmark);
}
// CHANGED: delete from KV with .delete()
async function deleteBookmark(id: string, env: Env): Promise<Response> {
const existing = await env.BOOKMARKS.get(id);
if (!existing) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
await env.BOOKMARKS.delete(id);
return Response.json({ message: 'Bookmark deleted' });
}
The listBookmarks function calls .list() once and then .get() for every key. This is called the N+1 pattern and it works fine for a small number of bookmarks, but gets slow as the dataset grows. The challenge at the end of this step shows how to avoid this using KV metadata. Also note that .list() returns at most 1,000 keys per call. For larger datasets, you would need to paginate using the cursor returned in the response.
| Operation | Method | Returns |
|---|---|---|
| Read | env.BOOKMARKS.get(key, 'json') | Parsed object or null |
| Write | env.BOOKMARKS.put(key, JSON.stringify(value)) | Promise<void> |
| Delete | env.BOOKMARKS.delete(key) | Promise<void> |
| List keys | env.BOOKMARKS.list() | { keys: [{ name }] } |
Step 3: Test Persistent Storage
Create and verify persistence
Create a bookmark:
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/kv/","title":"KV Docs"}'
List bookmarks:
curl -s http://localhost:8787/bookmarks | jq
Now stop the dev server (Ctrl+C) and restart it (npm run dev). Then list again:
curl -s http://localhost:8787/bookmarks | jq
If the bookmark is still there, KV is working.
Test all operations
Create a bookmark:
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/workers-ai/","title":"Workers AI Docs"}'
Get by ID (use the id from the create response):
curl -s http://localhost:8787/bookmarks/REPLACE_ID | jq
Delete:
curl -X DELETE http://localhost:8787/bookmarks/REPLACE_ID
Verify deletion (should return 404):
curl http://localhost:8787/bookmarks/REPLACE_ID
KV is eventually consistent. In production, a write may take up to 60 seconds to propagate to all 300+ data centers. Reads from the same location that wrote the data are immediate. In local development with Wrangler, all reads and writes are immediate.
This makes KV ideal for data that is read frequently and written infrequently (configuration, bookmarks, feature flags). For data that requires strong consistency, you will use D1 in the next step.
- Add expiring bookmarks: Use KV’s
expirationTtloption to create bookmarks that auto-delete after a set time. Hint:env.BOOKMARKS.put(id, data, { expirationTtl: 3600 })sets a 1-hour TTL. - Add metadata: KV supports storing metadata alongside values. Store the bookmark’s
titleas KV metadata so you can list bookmarks without fetching each value individually. Hint:env.BOOKMARKS.put(id, data, { metadata: { title } })andkeys.keys[i].metadata.
D1 Database with KV Caching
Migrate bookmarks to a D1 SQL database for relational queries and tags, while keeping KV as a read cache for fast lookups.
Prerequisites
- Completed Step 3
- bookmark-api with KV storage
Learning Objectives
- Define a SQL schema and apply migrations with D1
- Implement the cache-aside pattern with D1 as the source of truth and KV as a read cache
- Decide when to use relational storage vs key-value storage in the same application
KV is great for simple key-value lookups, but bookmarks need relational features: filtering by tag, sorting by date, searching by title. D1 provides SQL for this. You will migrate the primary storage to D1 and keep KV as a read cache. When a bookmark is requested by ID, the Worker checks KV first and falls back to D1. This pattern (cache-aside) is common in production applications.
Step 1: Create the D1 Database
npx wrangler d1 create bookmark-db
When prompted “Would you like Wrangler to add it on your behalf?”, type Y. Wrangler will then ask for a binding name. Enter DB so it matches the env.DB calls used throughout this workshop.
Regenerate types:
npx wrangler types
Step 2: Define the Schema
Create schema.sql in your project root:
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL,
tags TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
We use CREATE TABLE IF NOT EXISTS so you can safely re-run this file without losing data. If you need to reset the table during development, you can manually run DROP TABLE bookmarks; first, but be aware this deletes all existing bookmarks.
Apply it to the local database. Make sure the dev server is stopped before running this:
npx wrangler d1 execute bookmark-db --local --file=./schema.sql
If you see this error when testing, the migration above was not applied. Re-run the command and restart the dev server with npm run dev.
Step 3: Update the Worker
Replace src/index.ts with the following. Key changes from Step 3 are marked:
interface Bookmark {
id: string;
url: string;
title: string;
tags: string; // NEW: comma-separated tags
created_at: string; // NEW: from D1 DATETIME
}
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;
if (path === '/bookmarks' && method === 'GET') {
return listBookmarks(env, url);
}
if (path === '/bookmarks' && method === 'POST') {
return createBookmark(request, env);
}
const match = path.match(/^\/bookmarks\/([a-zA-Z0-9_-]+)$/);
if (match && method === 'GET') {
return getBookmark(match[1], env);
}
if (match && method === 'DELETE') {
return deleteBookmark(match[1], env);
}
if (path === '/') {
return Response.json({
name: 'Bookmark API',
version: '3.0.0',
storage: 'D1 + KV cache',
endpoints: [
'GET /bookmarks',
'GET /bookmarks?tag=docs',
'POST /bookmarks',
'GET /bookmarks/:id',
'DELETE /bookmarks/:id'
]
});
}
return Response.json({ error: 'Not Found' }, { status: 404 });
},
};
// CHANGED: query D1, support filtering by tag
async function listBookmarks(env: Env, url: URL): Promise<Response> {
const tag = url.searchParams.get('tag');
let results: Bookmark[];
if (tag) {
// Filter by tag using LIKE (tags is comma-separated)
const { results: rows } = await env.DB.prepare(
`SELECT * FROM bookmarks WHERE tags LIKE ? ORDER BY created_at DESC`
).bind(`%${tag}%`).all<Bookmark>();
results = rows;
} else {
const { results: rows } = await env.DB.prepare(
`SELECT * FROM bookmarks ORDER BY created_at DESC`
).all<Bookmark>();
results = rows;
}
return Response.json({ bookmarks: results, count: results.length });
}
// CHANGED: write to D1, then cache in KV
// NOTE on SQL safety: every user-provided value is passed through .bind(),
// which uses parameterized queries. This prevents SQL injection, even when
// using LIKE with wildcards like %${tag}%. Never concatenate user input
// directly into SQL strings.
async function createBookmark(request: Request, env: Env): Promise<Response> {
let body: { url?: string; title?: string; tags?: string };
try {
body = await request.json() as { url?: string; title?: string; tags?: string };
} catch {
return Response.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
);
}
if (!body.url || !body.title) {
return Response.json(
{ error: 'Missing required fields: url, title' },
{ status: 400 }
);
}
const id = crypto.randomUUID().slice(0, 8);
const tags = body.tags || '';
// Write to D1 (source of truth)
const result = await env.DB.prepare(
`INSERT INTO bookmarks (id, url, title, tags)
VALUES (?, ?, ?, ?)
RETURNING *`
).bind(id, body.url, body.title, tags).first<Bookmark>();
if (!result) {
return Response.json({ error: 'Failed to create bookmark' }, { status: 500 });
}
// Cache in KV for fast reads
await env.BOOKMARKS.put(id, JSON.stringify(result), { expirationTtl: 3600 });
return Response.json(result, { status: 201 });
}
// CHANGED: check KV cache first, fall back to D1
async function getBookmark(id: string, env: Env): Promise<Response> {
// Try KV cache first
const cached = await env.BOOKMARKS.get<Bookmark>(id, 'json');
if (cached) {
return Response.json({ ...cached, _cached: true });
}
// Fall back to D1
const bookmark = await env.DB.prepare(
'SELECT * FROM bookmarks WHERE id = ?'
).bind(id).first<Bookmark>();
if (!bookmark) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
// Populate cache for next time
await env.BOOKMARKS.put(id, JSON.stringify(bookmark), { expirationTtl: 3600 });
return Response.json(bookmark);
}
// CHANGED: delete from D1 and invalidate KV cache
async function deleteBookmark(id: string, env: Env): Promise<Response> {
const existing = await env.DB.prepare(
'SELECT id FROM bookmarks WHERE id = ?'
).bind(id).first();
if (!existing) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
// Delete from D1
await env.DB.prepare('DELETE FROM bookmarks WHERE id = ?').bind(id).run();
// Invalidate KV cache
await env.BOOKMARKS.delete(id);
return Response.json({ message: 'Bookmark deleted' });
}
This code implements the cache-aside (lazy-loading) pattern:
- Read: Check KV first. On cache miss, query D1 and populate KV.
- Write: Write to D1, then write to KV (write-through).
- Delete: Delete from D1, then delete from KV (cache invalidation).
The _cached: true field in the response lets you verify caching is working during testing.
Step 4: Test D1 + KV Caching
Create bookmarks with tags
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/workers/","title":"Workers Docs","tags":"docs,cloudflare,workers"}'
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/d1/","title":"D1 Docs","tags":"docs,cloudflare,database"}'
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://github.com/cloudflare/workers-sdk","title":"Workers SDK","tags":"github,cloudflare,tools"}'
Filter by tag
All bookmarks tagged “docs”:
curl -s "http://localhost:8787/bookmarks?tag=docs" | jq
All bookmarks tagged “cloudflare”:
curl -s "http://localhost:8787/bookmarks?tag=cloudflare" | jq
Verify caching
Get a bookmark by ID (first request comes from D1, no _cached field):
curl -s http://localhost:8787/bookmarks/REPLACE_ID | jq
Get the same bookmark again (second request comes from KV cache, should include "_cached": true):
curl -s http://localhost:8787/bookmarks/REPLACE_ID | jq
Verify persistence
Stop and restart the dev server. List bookmarks. They should still be there (D1 is persistent).
You are now using both in the same Worker:
| KV | D1 | |
|---|---|---|
| Role | Read cache | Source of truth |
| Access pattern | Get by key | SQL queries (filter, sort, join) |
| Consistency | Eventually consistent | Strongly consistent |
| Best for | High-read, low-write | Complex queries, relational data |
This is a common production pattern. KV handles the hot path (individual bookmark lookups) while D1 handles queries that need SQL (filtering by tag, listing with pagination).
- Add pagination: Modify
listBookmarksto accept?page=1&limit=10query parameters. UseLIMITandOFFSETin your SQL query. - Add a search endpoint: Add
GET /bookmarks?search=termthat searches thetitlecolumn usingWHERE title LIKE ?.
AI-Powered Summaries
Add Workers AI to automatically generate a summary for each bookmark when it is saved, storing the result in D1.
Prerequisites
- Completed Step 4
- bookmark-api with D1 and KV
Learning Objectives
- Integrate a text generation model into an existing data pipeline
- Design AI calls that fail gracefully without blocking core operations
- Evaluate when to run inference inline vs as a background task
When a user creates a bookmark, the API will now call Workers AI to generate a one-sentence summary of the URL based on its title. The summary is stored in D1 alongside the bookmark. If the AI call fails, the bookmark is still saved. The summary is a nice-to-have, not a blocker.
Workers AI models run on Cloudflare’s network, not locally. To test AI features during development, start the dev server with the --remote flag:
npx wrangler dev --remoteWithout --remote, AI calls will fail or return empty results. The --remote flag runs your Worker locally but executes binding calls (AI, KV, D1) against your real Cloudflare account. You must be logged in (npx wrangler whoami) for this to work.
Watch: Cloudflare Workers AI
This video covers the fundamentals of Workers AI.
Step 1: Add the AI Binding
Update wrangler.jsonc to add the AI binding:
{
"name": "bookmark-api",
"main": "src/index.ts",
"compatibility_date": "2026-03-01",
//...
"ai": {
"binding": "AI"
}
}
Regenerate types:
npx wrangler types
Step 2: Add a Summary Column to D1
Create migration-summary.sql:
ALTER TABLE bookmarks ADD COLUMN summary TEXT DEFAULT '';
Apply the migration to the remote database (Workers AI requires --remote):
npx wrangler d1 execute bookmark-db --remote --file=./migration-summary.sql
In production, you would use wrangler d1 migrations for versioned schema changes. For this workshop, a simple ALTER TABLE is sufficient.
Step 3: Update the Worker
The only function that changes is createBookmark. The rest of the file stays the same. Here is the updated src/index.ts:
interface Bookmark {
id: string;
url: string;
title: string;
tags: string;
summary: string; // NEW: AI-generated summary
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;
if (path === '/bookmarks' && method === 'GET') {
return listBookmarks(env, url);
}
if (path === '/bookmarks' && method === 'POST') {
return createBookmark(request, env);
}
const match = path.match(/^\/bookmarks\/([a-zA-Z0-9_-]+)$/);
if (match && method === 'GET') {
return getBookmark(match[1], env);
}
if (match && method === 'DELETE') {
return deleteBookmark(match[1], env);
}
if (path === '/') {
return Response.json({
name: 'Bookmark API',
version: '4.0.0',
storage: 'D1 + KV cache',
ai: 'Workers AI (auto-summary)',
endpoints: [
'GET /bookmarks',
'GET /bookmarks?tag=docs',
'POST /bookmarks',
'GET /bookmarks/:id',
'DELETE /bookmarks/:id'
]
});
}
return Response.json({ error: 'Not Found' }, { status: 404 });
},
};
// NEW: generate a summary using Workers AI
async function generateSummary(title: string, url: string, env: Env): Promise<string> {
try {
const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct-fast', {
messages: [
{
role: 'system',
content: 'You are a helpful assistant that writes concise bookmark descriptions. Respond with exactly one sentence, no more than 20 words.'
},
{
role: 'user',
content: `Write a one-sentence description for this bookmark:\nTitle: ${title}\nURL: ${url}`
}
]
});
// env.AI.run() returns an object like { response: "the text" }.
// So response.response accesses the generated text. This is not a typo!
return response.response?.trim() || '';
} catch (error) {
console.error('AI summary failed:', error);
return ''; // Fail gracefully - bookmark is saved without summary
}
}
// CHANGED: calls generateSummary before saving
async function createBookmark(request: Request, env: Env): Promise<Response> {
let body: { url?: string; title?: string; tags?: string };
try {
body = await request.json() as { url?: string; title?: string; tags?: string };
} catch {
return Response.json(
{ error: 'Invalid JSON in request body' },
{ status: 400 }
);
}
if (!body.url || !body.title) {
return Response.json(
{ error: 'Missing required fields: url, title' },
{ status: 400 }
);
}
const id = crypto.randomUUID().slice(0, 8);
const tags = body.tags || '';
// NEW: generate AI summary
const summary = await generateSummary(body.title, body.url, env);
// Write to D1 (now includes summary)
const result = await env.DB.prepare(
`INSERT INTO bookmarks (id, url, title, tags, summary)
VALUES (?, ?, ?, ?, ?)
RETURNING *`
).bind(id, body.url, body.title, tags, summary).first<Bookmark>();
if (!result) {
return Response.json({ error: 'Failed to create bookmark' }, { status: 500 });
}
// Cache in KV
await env.BOOKMARKS.put(id, JSON.stringify(result), { expirationTtl: 3600 });
return Response.json(result, { status: 201 });
}
// UNCHANGED from Step 4
async function listBookmarks(env: Env, url: URL): Promise<Response> {
const tag = url.searchParams.get('tag');
let results: Bookmark[];
if (tag) {
const { results: rows } = await env.DB.prepare(
`SELECT * FROM bookmarks WHERE tags LIKE ? ORDER BY created_at DESC`
).bind(`%${tag}%`).all<Bookmark>();
results = rows;
} else {
const { results: rows } = await env.DB.prepare(
`SELECT * FROM bookmarks ORDER BY created_at DESC`
).all<Bookmark>();
results = rows;
}
return Response.json({ bookmarks: results, count: results.length });
}
// UNCHANGED from Step 4
async function getBookmark(id: string, env: Env): Promise<Response> {
const cached = await env.BOOKMARKS.get<Bookmark>(id, 'json');
if (cached) {
return Response.json({ ...cached, _cached: true });
}
const bookmark = await env.DB.prepare(
'SELECT * FROM bookmarks WHERE id = ?'
).bind(id).first<Bookmark>();
if (!bookmark) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
await env.BOOKMARKS.put(id, JSON.stringify(bookmark), { expirationTtl: 3600 });
return Response.json(bookmark);
}
// UNCHANGED from Step 4
async function deleteBookmark(id: string, env: Env): Promise<Response> {
const existing = await env.DB.prepare(
'SELECT id FROM bookmarks WHERE id = ?'
).bind(id).first();
if (!existing) {
return Response.json({ error: 'Bookmark not found' }, { status: 404 });
}
await env.DB.prepare('DELETE FROM bookmarks WHERE id = ?').bind(id).run();
await env.BOOKMARKS.delete(id);
return Response.json({ message: 'Bookmark deleted' });
}
Notice that generateSummary catches errors and returns an empty string. The bookmark is always saved, even if the AI call fails. This is important: AI inference is not instant and can occasionally fail. Never let an optional feature block a core operation.
Step 4: Test AI Summaries
npx wrangler dev --remote
Workers AI includes a free tier with a daily limit on inference requests. If you hit rate limits during this lab, wait a few minutes and try again. See developers.cloudflare.com/workers-ai/platform/pricing/ for current limits.
Create a bookmark and check the summary
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/workers-ai/","title":"Workers AI Documentation","tags":"docs,ai"}' | jq
The response should include a summary field with an AI-generated description.
Verify summaries are stored in D1
# List bookmarks - each should have a summary
curl -s http://localhost:8787/bookmarks | jq '.bookmarks[].summary'
This workshop uses @cf/meta/llama-3.1-8b-instruct-fast, a good balance of quality and speed. Workers AI offers many models for different tasks:
- Text generation:
@cf/meta/llama-3.1-8b-instruct-fast,@cf/meta/llama-3.3-70b-instruct-fp8-fast,@cf/meta/llama-3.1-8b-instruct-fast - Text classification:
@cf/huggingface/distilbert-sst-2-int8 - Translation:
@cf/meta/m2m100-1.2b - Embeddings:
@cf/baai/bge-base-en-v1.5
See the full catalog at developers.cloudflare.com/workers-ai/models/.
- Add auto-tagging: Use Workers AI to suggest tags for a bookmark based on its title and URL. If the user does not provide tags, use the AI-suggested ones.
- Add a sentiment endpoint: Create
POST /sentimentthat uses the@cf/huggingface/distilbert-sst-2-int8model to classify the sentiment of a text string. Return the label and confidence score.
AI Gateway
Route your Workers AI calls through AI Gateway for caching, monitoring, and cost control. A small configuration change with big operational benefits.
Prerequisites
- Completed Step 5
- bookmark-api with Workers AI
Learning Objectives
- Route AI inference through a managed gateway for observability and cost control
- Choose cache TTLs based on response stability and freshness requirements
- Interpret gateway analytics to identify optimization opportunities
This is the smallest code change in the workshop. You will create an AI Gateway, add one environment variable, and pass a gateway option to each env.AI.run() call. Everything else stays the same. The payoff is significant: caching, analytics, rate limiting, and request logging for all your AI calls.
Step 1: Create the AI Gateway
- Go to the Cloudflare Dashboard
- Select AI > AI Gateway
- Click Create Gateway
- Name it
bookmark-gatewayand click Create
Step 2: Update the AI Calls
The change is small. In src/index.ts, update the generateSummary function that calls env.AI.run():
Update generateSummary
// CHANGED: added gateway option as third argument
async function generateSummary(title: string, url: string, env: Env): Promise<string> {
try {
const response = await env.AI.run('@cf/meta/llama-3.1-8b-instruct-fast', {
messages: [
{
role: 'system',
content: 'You are a helpful assistant that writes concise bookmark descriptions. Respond with exactly one sentence, no more than 20 words.'
},
{
role: 'user',
content: `Write a one-sentence description for this bookmark:\nTitle: ${title}\nURL: ${url}`
}
]
}, {
gateway: {
id: 'bookmark-gateway',
skipCache: false,
cacheTtl: 86400 // Cache summaries for 24 hours
}
});
return response.response?.trim() || '';
} catch (error) {
console.error('AI summary failed:', error);
return '';
}
}
That is the entire code change. The rest of the file stays exactly the same.
The gateway option is the third argument to env.AI.run():
id: Your gateway name (must match what you created in the Dashboard)skipCache: Set totrueto bypass the cache for this specific requestcacheTtl: How long (in seconds) to cache the response
If two requests send the same prompt to the same model, the second request will be served from the gateway cache without consuming AI inference.
Step 3: Test Gateway Caching
npx wrangler dev --remote
AI Gateway is a cloud service. When running locally without --remote, AI calls do not route through the gateway, so you will not see caching or analytics. Use --remote to test gateway features during development, or deploy with npx wrangler deploy and test against the live URL. The analytics dashboard (Step 4) only shows data from remote/deployed requests.
Test gateway caching with bookmarks
Create a bookmark:
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/workers/","title":"Workers Docs","tags":"docs"}'
Delete it and recreate with the same title and URL:
curl -X DELETE http://localhost:8787/bookmarks/REPLACE_ID
curl -X POST http://localhost:8787/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/workers/","title":"Workers Docs","tags":"docs"}'
The second creation sends the same prompt to the AI model. Because the gateway caches by prompt, this request should return faster with a similar summary served from cache.
Step 4: View Analytics in the Dashboard
After deploying (npx wrangler deploy), go to AI > AI Gateway > bookmark-gateway in the Dashboard. You will see:
- Request count - Total AI requests routed through the gateway
- Cache hit rate - Percentage served from cache (your cost savings)
- Latency - Average and p99 response times
- Token usage - Input and output tokens consumed
- Error rate - Failed AI requests
Configure rate limiting
In the gateway settings, you can set request limits per minute to prevent runaway costs from unexpected traffic.
Choose cache durations based on how stable the content is:
- Bookmark summaries (24h): Same title and URL always produce a similar summary
- Chat-style responses (skip or 5 min): Users expect dynamic responses
- Compare response times: Create the same bookmark twice (delete and recreate) and use
curl -w "\nTime: %{time_total}s\n"to compare the response time of the first request (fresh inference) vs the second request (gateway cache hit). How much faster is the cached response? - Experiment with cache TTL: Try changing the
cacheTtlvalue ingenerateSummaryand observe how it affects cache behavior in the gateway dashboard.
Deploy to Production
Deploy your Bookmark API to Cloudflare's global network, verify the deployment, and learn how to monitor and roll back.
Prerequisites
- Completed Step 6
- Cloudflare account
- Wrangler CLI installed
Learning Objectives
- Ship a Worker to Cloudflare's global network and verify it is live
- Store sensitive configuration securely using Wrangler secrets
- Monitor production traffic and roll back a bad deployment
Step 1: Prepare Remote Resources
First, verify you are authenticated:
npx wrangler whoami
Apply the base schema to the remote D1 database. The summary column migration was already applied remotely in Step 5, so you only need the initial schema:
npx wrangler d1 execute bookmark-db --remote --file=./schema.sql
In Step 4, the d1 execute command used --local, which writes to a local SQLite file. The remote D1 database does not have the bookmarks table yet. Skipping this step is the most common cause of “table not found” errors after first deploy.
You can optionally test against your real remote bindings before deploying:
npx wrangler dev --remote
Step 2: Deploy Your Worker
npx wrangler deploy
Wrangler outputs your bindings and the live URL:
Your Worker has access to the following bindings:
- KV Namespace (env.BOOKMARKS)
- D1 Database (env.DB)
- AI (env.AI)
Deployed bookmark-api triggers
https://bookmark-api.<your-subdomain>.workers.dev
Verify it is working:
# Check the root endpoint
curl -s https://bookmark-api.<your-subdomain>.workers.dev/ | jq
Create a bookmark on the live API:
curl -X POST https://bookmark-api.<your-subdomain>.workers.dev/bookmarks \
-H "Content-Type: application/json" \
-d '{"url":"https://developers.cloudflare.com/workers/","title":"Workers Docs","tags":"docs"}'
Your KV namespace, D1 database, and AI binding all work automatically in production. Wrangler resolves the binding IDs from your wrangler.jsonc when deploying.
Step 3: Monitor and Roll Back
Stream live logs
npx wrangler tail
This streams console.log output and request metadata from your production Worker in real time.
Roll back a deployment
# List recent deployments
npx wrangler deployments list
# Roll back to the previous version
npx wrangler rollback
You have built a Worker with HTTP routing, KV storage, D1 databases, Workers AI, and AI Gateway. Here are ways to keep going:
- Hono framework: Add structured routing with middleware
- Durable Objects: Build stateful, coordinated applications
- R2 storage: Store and serve files from the edge
- Queues: Process work asynchronously
- Cron Triggers: Run Workers on a schedule
You completed the Workers lab!
You went from a Hello World Worker to a production-deployed API with storage, databases, and AI on Cloudflare's global network.
What you built
- 01 Set up a Cloudflare Worker and deployed Hello World
- 02 Built HTTP routing and request handling with Hono
- 03 Added KV storage for fast edge caching
- 04 Integrated D1 for relational database storage
- 05 Added AI-powered features with Workers AI
- 06 Connected AI Gateway for observability and caching
- 07 Deployed to Cloudflare's global network with secrets and monitoring
What to build next
Share what you built with #CloudflareWorkers and connect with the community!
Learning Resources
Documentation for every tool used in this workshop.
Workers Documentation
API references, configuration guides, and best practices for the Workers runtime
READ DOCS