Skip to main content

Pages Functions (Edge Functions)

Learning Focus

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
Functions Directory

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

functions/api/hello.ts
// 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

ExportHTTP Method
onRequestAll methods
onRequestGetGET
onRequestPostPOST
onRequestPutPUT
onRequestPatchPATCH
onRequestDeleteDELETE
onRequestOptionsOPTIONS
onRequestHeadHEAD

Routing

Static Routes

FileURL
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)
functions/api/users/[id].ts
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):

functions/_middleware.ts — security headers middleware
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

functions/api/_middleware.ts — API-only middleware
// 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

functions/api/feedback.ts
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)

functions/api/search.ts
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)

functions/api/views/[slug].ts
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

functions/api/_middleware.ts — CORS middleware
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:

wrangler.toml — Pages Function bindings
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:

Configure bindings 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

Run functions locally with wrangler
# 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.ts intercepts all requests — ideal for auth, CORS, and security headers.
  • Dynamic routes use [param].ts syntax — access via context.params.param.
  • Bind KV, D1, R2, and secrets via wrangler.toml or the API.
  • Use wrangler pages dev to run functions locally alongside your static site.

What's Next