Interactive Reference · Always Learning
Build at theEdge. Forever.
Your permanent playbook for the Astro + Cloudflare D1 + KV + PageFind + Sessions stack. Study each chapter, copy real patterns, ship confidently.
🚀 Astro 5
📦 KV
🗄️ D1
🔍 PageFind
🔐 Sessions
Default Mode
Static (SSG). Opt-in SSR per-route with
export const prerender = falseCF Runtime
Use
@astrojs/cloudflare. Access bindings via Astro.locals.runtime.envIslands
Zero JS by default. Hydrate with
client:load, client:idle, client:visibleKey Files
astro.config.mjs · wrangler.toml · src/env.d.ts// Request → Response Flow
Browser
Request
Request
→
CF Pages
Edge Worker
Edge Worker
→
KV / D1
Data Layer
Data Layer
→
Astro SSR
Render
Render
→
PageFind
Index
Index
→
HTML
Response
Response
⚙️
astro.config.mjs
Configimport { defineConfig } from 'astro/config'; import cloudflare from '@astrojs/cloudflare'; export default defineConfig({ output: 'hybrid', // SSG + opt-in SSR adapter: cloudflare({ mode: 'directory', platformProxy: { enabled: true } }), vite: { define: { 'process.env': {} } } });
📋
wrangler.toml
Bindingsname = "my-astro-app" compatibility_date = "2024-09-23" pages_build_output_dir = "./dist" # KV Namespace [[kv_namespaces]] binding = "MY_KV" id = "abc123..." # D1 Database [[d1_databases]] binding = "DB" database_name = "my-db" database_id = "xyz789..."
🏝️
SSR API Route
SSR// Opt out → becomes SSR export const prerender = false; import type { APIRoute } from 'astro'; export const GET: APIRoute = async ({ locals }) => { const { MY_KV, DB } = locals.runtime.env; const val = await MY_KV.get('key'); return Response.json({ val }); };
🔷
Type-safe env.d.ts
Types/// <reference types="astro/client" /> type Runtime = import('@astrojs/cloudflare') .Runtime<Env>; interface Env { MY_KV: KVNamespace; DB: D1Database; SESSION_SECRET: string; } declare namespace App { interface Locals extends Runtime { user?: { id: string; email: string; }; } }
💡 Hybrid Strategy: Use
output: 'hybrid'. All pages static by default. Opt into SSR only for API routes and auth-protected pages with export const prerender = false.Content Collections with Zod validation ▼
Define schemas in
src/content/config.ts using defineCollection + Zod. Collections live in src/content/[name]/ as .md or .mdx. Query with getCollection('posts'). All frontmatter is type-safe at build time — bad data breaks the build, not production.Island Architecture: client:* directives ▼
•
•
•
•
•
client:load — hydrate immediately (above fold)•
client:idle — hydrate when browser is idle•
client:visible — hydrate when entering viewport•
client:media="(max-width:768px)" — on media query•
client:only="react" — skip SSR entirelyBest For
Session tokens, feature flags, rate limiting, cached HTML, user prefs
Consistency
Eventually consistent — global replication up to 60s. Not for financial data.
Limits
Values up to
25 MB. Keys up to 512 bytes. Optional TTL per key.CLI
wrangler kv namespace create · kv key put · kv key list📝
CRUD Operations
Core APIconst kv = locals.runtime.env.MY_KV; // Write with TTL (seconds) await kv.put('user:123', JSON.stringify(data), { expirationTtl: 86400 } ); // Read as text / JSON const raw = await kv.get('user:123'); const obj = await kv.get('user:123', 'json'); // List keys by prefix const { keys } = await kv.list({ prefix: 'user:' }); // Delete await kv.delete('user:123');
🚀
Metadata + SWR Pattern
Advanced// Store value + metadata await kv.put('page:home', html, { metadata: { cachedAt: Date.now(), version: 'v2' }, expirationTtl: 3600 }); const { value, metadata } = await kv.getWithMetadata( 'page:home' ); // Stale-while-revalidate if (value && metadata.version === 'v2') { return new Response(value); }
⏱️
Rate Limiting
Patternexport async function rateLimit( kv: KVNamespace, ip: string ) { const key = `rate:${ip}`; const LIMIT = 100, WINDOW = 60; const n = await kv.get(key, 'json') as number ?? 0; if (n >= LIMIT) return { limited: true }; await kv.put( key, JSON.stringify(n + 1), { expirationTtl: WINDOW } ); return { limited: false, count: n+1 }; }
⚠️ KV vs D1: KV = key-value cache (sessions, flags, config). D1 = relational SQL (structured data, joins). Never try to do complex queries in KV — that's what D1 is for.
Engine
SQLite. Strongly consistent primary. Read replicas globally via Cloudflare network.
Size Limit
Up to
10 GB paid · 500 MB free tierMigrations
wrangler d1 migrations create then apply --local or --remoteLocal Dev
wrangler dev --local — uses a SQLite file automatically🏗️
Migration SQL
SQLCREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, role TEXT DEFAULT 'user', created_at INTEGER DEFAULT (unixepoch()) ); CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), slug TEXT NOT NULL UNIQUE, title TEXT NOT NULL, published INTEGER DEFAULT 0 ); CREATE INDEX idx_posts_user ON posts(user_id);
🔍
Prepared Statements
APIexport const prerender = false; const db = Astro.locals.runtime.env.DB; const { slug } = Astro.params; // Always use .bind() — never // concatenate user input! const post = await db .prepare(` SELECT p.*, u.name as author FROM posts p JOIN users u ON p.user_id = u.id WHERE p.slug = ? AND p.published = 1 `) .bind(slug) .first(); if (!post) return Astro.redirect('/404');
📦
Batch Statements
Advanced// Atomic — all succeed or all fail const [r1, r2] = await db.batch([ db.prepare( 'INSERT INTO users (email, name)' + ' VALUES (?, ?)' ).bind(email, name), db.prepare( 'INSERT INTO posts' + ' (user_id, slug, title)' + ' VALUES (?, ?, ?)' ).bind(userId, slug, title) ]); const newId = r1.meta.last_row_id;
⚡
CLI Workflow
CLI# Create database wrangler d1 create my-db # New migration file wrangler d1 migrations create \ my-db add_posts_table # Apply locally wrangler d1 migrations apply \ my-db --local # Apply to production wrangler d1 migrations apply \ my-db --remote # Quick query wrangler d1 execute my-db \ --command "SELECT * FROM users"
🔒 Always use prepared statements: Never concatenate user input into SQL strings. Use
.prepare('SELECT * FROM t WHERE id = ?').bind(id) — D1 handles parameterization at the protocol level, preventing SQL injection.D1 vs KV: When to use which? ▼
Use D1 when: You need structured data, joins, complex queries, ACID transactions, or strong consistency.
Use KV when: You need globally fast reads with eventual consistency — sessions, feature flags, cached responses. KV read latency ~1ms globally from cache. D1 requires a round-trip to the primary.
Use KV when: You need globally fast reads with eventual consistency — sessions, feature flags, cached responses. KV read latency ~1ms globally from cache. D1 requires a round-trip to the primary.
How It Works
Crawls built HTML at deploy time. Binary index served as static CDN assets.
Zero Cost
No server, no API. Search runs in the browser via Wasm. Scales to millions of pages.
Integration
Run after
astro build. Index lives in dist/pagefind/.Arabic/RTL
Auto-detects from
<html lang="ar">. Applies Arabic tokenization automatically.🔧
Setup & Build
Config{ "scripts": { "build": "astro build && pagefind --site dist", "dev": "astro dev" }, "devDependencies": { "@pagefind/default-ui": "^1.0.0" } }
🎨
Search UI Component
Component<div id="search"></div> <script> import { PagefindUI } from '@pagefind/default-ui'; new PagefindUI({ element: '#search', showImages: true, showEmptyFilters: false, resetStyles: false }); </script>
🏷️
Annotate HTML
HTML<!-- Index only this region --> <main data-pagefind-body> <!-- Custom title metadata --> <h1 data-pagefind-meta="title" >Page Title</h1> <!-- Exclude nav from index --> <nav data-pagefind-ignore> ... </nav> <!-- Custom filter --> <span data-pagefind-filter="category" style="display:none" >tutorial</span> </main>
🧪
JS API (headless)
Advanced// Full control without default UI const pagefind = await import( '/pagefind/pagefind.js' ); await pagefind.options({ excerptLength: 20 }); const search = await pagefind.search("astro"); const results = await Promise.all( search.results.map( r => r.data() ) );
🌐 Arabic/RTL: Set
<html lang="ar"> and PageFind applies Arabic tokenization automatically — perfect for MENA projects. For bilingual sites, each language is indexed separately.🎯 When does PageFind build its search index?
Architecture
Session data in KV. Signed session ID in
HttpOnly cookie. Validated in middleware.Middleware
Lives in
src/middleware.ts. Runs on every SSR request before page renders.CSRF
SameSite=Lax + validate Origin header on mutations prevents CSRF.Signing
Use native
crypto.subtle — HMAC-sign tokens with zero npm dependencies.🔗
Astro Middleware
Coreimport { defineMiddleware } from 'astro:middleware'; import { getSession } from './lib/session'; export const onRequest = defineMiddleware( async ({ locals, cookies, url, redirect }, next) => { const kv = locals.runtime.env.MY_KV; const sid = cookies .get('session_id')?.value; if (sid) { const sess = await getSession(kv, sid); if (sess) locals.user = sess.user; } const guarded = url.pathname .startsWith('/dashboard'); if (guarded && !locals.user) return redirect('/login'); return next(); } );
🔑
Session Library
Libimport { nanoid } from 'nanoid'; const TTL = 60 * 60 * 24 * 7; export async function createSession( kv: KVNamespace, user: SessionUser ) { const id = nanoid(32); await kv.put( `session:${id}`, JSON.stringify({ user, createdAt: Date.now() }), { expirationTtl: TTL } ); return id; } export async function getSession( kv: KVNamespace, id: string ) { return kv.get( `session:${id}`, 'json' ); } export async function destroySession( kv: KVNamespace, id: string ) { await kv.delete( `session:${id}` ); }
🍪
Login API Route
Authexport const prerender = false; import { createSession } from '../../lib/session'; export const POST: APIRoute = async ({ request, cookies, locals, redirect }) => { const { MY_KV: kv, DB: db } = locals.runtime.env; const body = await request.formData(); const email = body.get('email') as string; const user = await db .prepare( 'SELECT * FROM users' + ' WHERE email = ?' ) .bind(email).first(); if (!user) return Response.json( { error: 'Invalid' }, { status: 401 } ); const sid = await createSession(kv, user); cookies.set('session_id', sid, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60*60*24*7 }); return redirect('/dashboard'); };
🔒 Cookie Security Checklist: Always set
httpOnly: true (blocks XSS), secure: true (HTTPS only), sameSite: 'lax' (CSRF protection). Store only the session ID in the cookie — never the actual user data or permissions.Complete Auth Flow: Browser → D1 → KV ▼
1. User submits login form → POST /api/login
2. API route queries D1 to find user + verify password hash
3. On success →
4. Set cookie:
5. All future requests → Middleware reads cookie → KV lookup → sets
6. Protected pages check
7. Logout →
2. API route queries D1 to find user + verify password hash
3. On success →
createSession(kv, user) stores data in KV with 7-day TTL4. Set cookie:
session_id=<nanoid> HttpOnly + Secure + SameSite=Lax5. All future requests → Middleware reads cookie → KV lookup → sets
locals.user6. Protected pages check
locals.user, redirect to /login if missing7. Logout →
destroySession(kv, id) + clear cookieHMAC Signing with Web Crypto API ▼
Cloudflare Workers have native access to
Create stateless, verifiable tokens at the edge with zero cold start impact.
crypto.subtle — zero dependencies needed:const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data))Create stateless, verifiable tokens at the edge with zero cold start impact.
🎯 Where should user role + permissions be stored?
The Edge Stack Playbook
Astro · KV · D1 · PageFind · Sessions
Always deploying to Cloudflare Pages 🚀