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​