Edge Stack Playbook
0/5
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
🚀
Chapter 01
Astro Framework
SSG + SSR hybrid · Islands · Cloudflare adapter · Content Collections
Default Mode
Static (SSG). Opt-in SSR per-route with export const prerender = false
CF Runtime
Use @astrojs/cloudflare. Access bindings via Astro.locals.runtime.env
Islands
Zero JS by default. Hydrate with client:load, client:idle, client:visible
Key Files
astro.config.mjs · wrangler.toml · src/env.d.ts
// Request → Response Flow
Browser
Request
CF Pages
Edge Worker
KV / D1
Data Layer
Astro SSR
Render
PageFind
Index
HTML
Response
⚙️
astro.config.mjs
Config
astro.config.mjs
import { 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
Bindings
wrangler.toml
name = "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
src/pages/api/data.ts
// 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
src/env.d.ts
/// <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 entirely

📦
Chapter 02
Cloudflare KV
Eventually consistent · Global edge cache · Sessions, config & caching
Best 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 API
kv-operations.ts
const 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
kv-metadata.ts
// 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
Pattern
middleware/rateLimit.ts
export 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.

🗄️
Chapter 03
Cloudflare D1
SQLite at the edge · Migrations · Prepared statements · Strong consistency
Engine
SQLite. Strongly consistent primary. Read replicas globally via Cloudflare network.
Size Limit
Up to 10 GB paid · 500 MB free tier
Migrations
wrangler d1 migrations create then apply --local or --remote
Local Dev
wrangler dev --local — uses a SQLite file automatically
🏗️
Migration SQL
SQL
migrations/0001_init.sql
CREATE 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
API
[slug].astro
export 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
d1-batch.ts
// 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
terminal
# 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.

🔍
Chapter 04
PageFind Search
Static search index · Zero server cost · Built at deploy time · Wasm-powered
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
package.json
{
  "scripts": {
    "build":
      "astro build && pagefind --site dist",
    "dev": "astro dev"
  },
  "devDependencies": {
    "@pagefind/default-ui": "^1.0.0"
  }
}
🎨
Search UI Component
Component
src/components/Search.astro
<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
src/layouts/Base.astro
<!-- 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
custom-search.ts
// 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?
A. At runtime when a user types in the search box
B. On every server request
C. At build time, after astro build completes
D. When Cloudflare Pages finishes deploying

🔐
Chapter 05
Sessions & Auth
KV-backed sessions · Signed cookies · Astro middleware · Auth patterns
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
Core
src/middleware.ts
import { 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
Lib
src/lib/session.ts
import { 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
Auth
src/pages/api/login.ts
export 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 → createSession(kv, user) stores data in KV with 7-day TTL
4. Set cookie: session_id=<nanoid> HttpOnly + Secure + SameSite=Lax
5. All future requests → Middleware reads cookie → KV lookup → sets locals.user
6. Protected pages check locals.user, redirect to /login if missing
7. Logout → destroySession(kv, id) + clear cookie
HMAC Signing with Web Crypto API
Cloudflare Workers have native access to 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?
A. Directly in the cookie value (signed JWT)
B. In KV, referenced by the session ID in the cookie
C. In a JS variable in the browser
D. In localStorage on the client
The Edge Stack Playbook
Astro · KV · D1 · PageFind · Sessions
Always deploying to Cloudflare Pages 🚀