Pages Functions (Edge Functions)
By the end of this module you will be able to write and deploy Pages Functions — adding API endpoints, middleware, and dynamic behavior to your otherwise static Docusaurus site.
What Are Pages Functions?
Pages Functions are serverless functions that run alongside your static Pages site. They are backed by Cloudflare Workers — the same V8 isolate runtime — with these Pages-specific features:
- File-based routing — file path = URL path
- Colocated with static assets in the same project
- Same binding system — KV, D1, R2, Queues, etc.
- Middleware support — intercept all requests
Directory Structure
my-docs/ ← Docusaurus root
├── docs/ ← Markdown content
├── src/
├── static/
│ └── _redirects ← URL redirects
├── functions/ ← ← ← Pages Functions live here
│ ├── _middleware.ts ← Runs on EVERY request
│ ├── api/
│ │ ├── search.ts → /api/search
│ │ ├── feedback.ts → /api/feedback
│ │ └── [slug].ts → /api/:slug (dynamic route)
│ └── sitemap.ts → /sitemap (override static file)
├── docusaurus.config.js
└── package.json
The functions/ directory must be at the root of your repository, not inside the Docusaurus site folder. Cloudflare Pages scans for it alongside your build output.
Basic Function Anatomy
// Type-safe with Cloudflare's TypeScript types
export interface Env {
// Define your bindings here
KV_CACHE: KVNamespace;
DB: D1Database;
MY_SECRET: string;
}
// Handle GET requests to /api/hello
export const onRequestGet: PagesFunction<Env> = async (context) => {
return Response.json({
message: "Hello from Pages Functions!",
timestamp: new Date().toISOString(),
cf: context.request.cf, // Cloudflare metadata (country, city, etc.)
});
};
// Handle POST requests to /api/hello
export const onRequestPost: PagesFunction<Env> = async (context) => {
const body = await context.request.json();
return Response.json({ received: body, status: "ok" });
};
// Catch-all handler (all HTTP methods)
export const onRequest: PagesFunction<Env> = async (context) => {
const method = context.request.method;
return new Response(`Method ${method} not supported`, { status: 405 });
};
Named Exports by HTTP Method
| Export | HTTP Method |
|---|---|
onRequest | All methods |
onRequestGet | GET |
onRequestPost | POST |
onRequestPut | PUT |
onRequestPatch | PATCH |
onRequestDelete | DELETE |
onRequestOptions | OPTIONS |
onRequestHead | HEAD |
Routing
Static Routes
| File | URL |
|---|---|
functions/api/search.ts | /api/search |
functions/api/v2/users.ts | /api/v2/users |
functions/contact.ts | /contact |
Dynamic Routes
Use [param] for route parameters:
functions/
├── docs/
│ └── [slug].ts → /docs/:slug → context.params.slug
├── api/
│ └── users/
│ └── [id].ts → /api/users/:id → context.params.id
└── [[catchall]].ts → /* (catch-all, lowest priority)
export const onRequestGet: PagesFunction = async (context) => {
const { id } = context.params;
// Fetch user data
const user = await context.env.DB.prepare(
"SELECT * FROM users WHERE id = ?"
).bind(id).first();
if (!user) {
return new Response("User not found", { status: 404 });
}
return Response.json(user);
};
Middleware
Create functions/_middleware.ts to intercept all requests (static assets + functions):
export const onRequest: PagesFunction = async (context) => {
// Run the next handler (function or static asset)
const response = await context.next();
// Clone and add security headers
const newResponse = new Response(response.body, response);
newResponse.headers.set("X-Frame-Options", "DENY");
newResponse.headers.set("X-Content-Type-Options", "nosniff");
newResponse.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return newResponse;
};
Middleware in a Subdirectory
// This only runs for /api/* routes
export const onRequest: PagesFunction = async (context) => {
// Check API token for all /api/* requests
const token = context.request.headers.get("X-API-Token");
if (!token || token !== context.env.API_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
return context.next();
};
Middleware Chain
Multiple _middleware.ts files apply in order (root → subdirectory):
functions/_middleware.ts → runs first (logging, CORS)
functions/api/_middleware.ts → runs second (auth for /api/*)
functions/api/search.ts → runs last (actual handler)
Real-World Patterns for Docusaurus
Pattern 1: Doc Feedback API
export interface Env {
DB: D1Database;
}
export const onRequestPost: PagesFunction<Env> = async (context) => {
const { page, type, comment } = await context.request.json<{
page: string;
type: 'helpful' | 'not_helpful';
comment?: string;
}>();
await context.env.DB.prepare(
`INSERT INTO feedback (page, type, comment, created_at)
VALUES (?, ?, ?, datetime('now'))`
).bind(page, type, comment || null).run();
return Response.json({ success: true });
};
Pattern 2: Search Proxy (Custom Algolia Proxy)
export interface Env {
ALGOLIA_APP_ID: string;
ALGOLIA_API_KEY: string;
}
export const onRequestGet: PagesFunction<Env> = async (context) => {
const url = new URL(context.request.url);
const query = url.searchParams.get("q") || "";
if (!query) {
return Response.json({ hits: [] });
}
const response = await fetch(
`https://${context.env.ALGOLIA_APP_ID}-dsn.algolia.net/1/indexes/docs/query`,
{
method: "POST",
headers: {
"X-Algolia-Application-Id": context.env.ALGOLIA_APP_ID,
"X-Algolia-API-Key": context.env.ALGOLIA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ query, hitsPerPage: 10 }),
}
);
const data = await response.json();
return Response.json(data);
};
Pattern 3: Visitor Counter (KV-backed)
export interface Env {
PAGE_VIEWS: KVNamespace;
}
export const onRequestGet: PagesFunction<Env> = async (context) => {
const { slug } = context.params;
const key = `views:${slug}`;
const current = parseInt(await context.env.PAGE_VIEWS.get(key) || "0");
const updated = current + 1;
// Increment (fire-and-forget, don't await)
context.waitUntil(
context.env.PAGE_VIEWS.put(key, updated.toString(), {
expirationTtl: 86400 * 365, // 1 year
})
);
return Response.json({ slug, views: updated });
};
Pattern 4: CORS Headers for External API Calls
const ALLOWED_ORIGINS = [
"https://docs.yourdomain.com",
"https://staging.my-docs.pages.dev",
];
export const onRequest: PagesFunction = async (context) => {
const origin = context.request.headers.get("Origin") || "";
// Handle preflight
if (context.request.method === "OPTIONS") {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : "",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
},
});
}
const response = await context.next();
const newResponse = new Response(response.body, response);
if (ALLOWED_ORIGINS.includes(origin)) {
newResponse.headers.set("Access-Control-Allow-Origin", origin);
}
return newResponse;
};
Bindings in Pages Functions
Bindings connect your Functions to Cloudflare services. Configure them in wrangler.toml:
name = "my-docs"
pages_build_output_dir = "build"
compatibility_date = "2024-04-01"
# KV Namespace
[[kv_namespaces]]
binding = "PAGE_VIEWS"
id = "your-kv-namespace-id"
preview_id = "your-preview-kv-namespace-id"
# D1 Database
[[d1_databases]]
binding = "DB"
database_name = "docs-feedback"
database_id = "your-d1-database-id"
# R2 Bucket
[[r2_buckets]]
binding = "UPLOADS"
bucket_name = "my-docs-uploads"
# Environment variable (non-secret)
[vars]
ENVIRONMENT = "production"
Or configure via API:
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${CF_ACCOUNT_ID}/pages/projects/${CF_PROJECT}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"deployment_configs": {
"production": {
"kv_namespaces": {
"PAGE_VIEWS": { "namespace_id": "your-kv-namespace-id" }
},
"d1_databases": {
"DB": { "id": "your-d1-database-id" }
}
}
}
}' | jq .
Local Development with Functions
# Builds Docusaurus first, then starts Pages dev server with functions
npm run build && wrangler pages dev build
# Or point at Docusaurus dev server
# Terminal 1:
npm run start # Docusaurus at port 3000
# Terminal 2:
wrangler pages dev http://localhost:3000 --port 8788
# Visit http://localhost:8788 — static assets proxied to Docusaurus dev server,
# /api/* routes handled by your Functions locally
Key Takeaways
- Pages Functions live in the
functions/directory at the repo root — file path = URL path. - Export named handlers by HTTP method:
onRequestGet,onRequestPost, etc. functions/_middleware.tsintercepts all requests — ideal for auth, CORS, and security headers.- Dynamic routes use
[param].tssyntax — access viacontext.params.param. - Bind KV, D1, R2, and secrets via
wrangler.tomlor the API. - Use
wrangler pages devto run functions locally alongside your static site.
What's Next
- Continue to CI/CD — GitHub Actions Integration to automate deployments with full pipeline control.