Cloudflare Pages Functions Middleware
Cloudflare Pages serves static files from its edge with no code at all, and for most of a static site that is exactly right. Occasionally a site needs a few lines of logic on the request path: a security header that must include a per-request nonce, a password check for a staging section, a redirect that depends on a cookie, routing a slice of traffic to an experimental page. Pages Functions middleware is a _middleware.js file that runs before static assets in its directory, can change the request or response, and hands off to the static file with next().
This guide adds three pieces of middleware to a Pages-hosted documentation site — per-request CSP nonces, basic authentication on an internal section, and a cookie-based A/B split for one landing page — scopes each to the paths that need it, and measures latency and invocation cost. It is part of Cloudflare Pages Edge Caching Setup, and relates to the broader patterns in Serverless Functions for Static Sites.
Prerequisites
- A static site deployed to Cloudflare Pages (Git integration or
wrangler pages deploy). - A
functions/directory at the project root (created alongside the build output, not inside it). - Wrangler 3.80+ for local testing with
wrangler pages dev.
How Middleware Is Structured
Middleware files follow the directory structure. functions/_middleware.js runs for every request; functions/internal/_middleware.js runs only for /internal/*. A middleware exports onRequest (or method-specific variants), receives a context with request, env, next and data, and must return a Response:
// functions/_middleware.js — the minimal shape
export async function onRequest(context) {
const response = await context.next(); // continue to the static asset or next function
const out = new Response(response.body, response);
out.headers.set('X-Served-By', 'pages-middleware');
return out;
}
Middleware can also be an array of functions, run in order, which keeps concerns separate: export const onRequest = [securityHeaders, auth];.
Middleware 1: Per-Request CSP Nonces
A strict Content Security Policy with a nonce needs a new random value per response, inserted into both the header and the <script> tags. Hashes are usually better for static sites, as in Hash-Based CSP for Inline Scripts in Astro, but this site embedded a third-party script whose inline bootstrap changed per page. HTMLRewriter adds the nonce while streaming, without buffering the page:
// functions/_middleware.js
export async function onRequest({ next }) {
const res = await next();
if (!res.headers.get('content-type')?.includes('text/html')) return res;
const nonce = btoa(crypto.getRandomValues(new Uint8Array(16)).join(''));
const csp = `default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`;
const rewritten = new HTMLRewriter()
.on('script', { element: (el) => el.setAttribute('nonce', nonce) })
.transform(res);
const out = new Response(rewritten.body, rewritten);
out.headers.set('Content-Security-Policy', csp);
out.headers.set('Cache-Control', 'private, no-store'); // nonced HTML must not be shared
return out;
}
The Cache-Control change matters: a cached nonced page would reuse the nonce for every reader, which defeats it. That makes every HTML request uncacheable at the edge — a real cost, measured below.
Middleware 2: Basic Auth on One Section
The /internal/ section holds runbooks for the support team. A middleware in that directory checks a basic-auth header against a secret:
// functions/internal/_middleware.js
export async function onRequest({ request, env, next }) {
const expected = 'Basic ' + btoa(`support:${env.INTERNAL_PASSWORD}`);
if (request.headers.get('Authorization') !== expected) {
return new Response('Authentication required', { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="internal"', 'Cache-Control': 'no-store' } });
}
const res = await next();
const out = new Response(res.body, res);
out.headers.set('X-Robots-Tag', 'noindex');
out.headers.set('Cache-Control', 'private, no-store');
return out;
}
Because the file lives in functions/internal/, public pages never invoke it. For stronger protection, Cloudflare Access in front of the path replaces the password with single sign-on, as in Protecting a Static Site Behind Authentication.
Middleware 3: A Cookie-Based A/B Split
One landing page had two variants built as static pages, /pricing/ and /pricing/b/. Middleware on /pricing/ assigns a variant once, remembers it in a cookie, and serves the matching file with env.ASSETS.fetch — a rewrite, so the URL does not change:
// functions/pricing/_middleware.js
export async function onRequest({ request, env, next }) {
const url = new URL(request.url);
if (url.pathname !== '/pricing/') return next();
const cookie = request.headers.get('Cookie') ?? '';
const variant = /ab_pricing=(a|b)/.exec(cookie)?.[1] ?? (Math.random() < 0.5 ? 'a' : 'b');
const res = variant === 'b' ? await env.ASSETS.fetch(new URL('/pricing/b/', url)) : await next();
const out = new Response(res.body, res);
out.headers.append('Set-Cookie', `ab_pricing=${variant}; Path=/; Max-Age=2592000; SameSite=Lax; Secure`);
out.headers.set('Cache-Control', 'private, no-store');
out.headers.set('Vary', 'Cookie');
return out;
}
Variant B's page carries a canonical to /pricing/ and noindex, so search engines see one page.
Because variants are ordinary static pages, both are built, previewed and tested like any other page, and ending the experiment is a matter of deleting the middleware file and the losing page. The split was checked by sampling 2,000 requests with and without cookies: assignment was 50.3% to 49.7%, and every repeat request with a cookie received its original variant. Analytics records the variant from a data-variant attribute that each page's HTML carries, so no client-side experiment script is needed.
Scoping With _routes.json
A root _middleware.js makes every request — every image, script and font — invoke a function, even if the code returns early. _routes.json in the build output tells Pages which paths invoke functions at all:
{
"version": 1,
"include": ["/*"],
"exclude": ["/_astro/*", "/img/*", "/fonts/*", "/*.xml", "/*.txt", "/favicon.ico", "/pagefind/*"]
}
With assets excluded, the CSP middleware ran only for HTML. Before scoping, a typical page view invoked the function 23 times (HTML plus assets); after, once.
Testing Middleware Locally and in CI
wrangler pages dev dist runs the static output and the functions/ directory together locally, with bindings and secrets from a .dev.vars file, so middleware can be exercised before deploying. The redirect and header test table described in Testing Redirects and Headers Before Deploy gained three rows: /internal/ without credentials must return 401 with no-store; /pricing/ without a cookie must set ab_pricing; and any HTML page must carry the expected CSP. Running that table against every preview deploy caught one regression, where a refactor of the auth middleware returned the protected page with a public cache header for a single preview — exactly the kind of bug that would be serious in production.
Measured Impact
| Measure | Static only | With scoped middleware |
|---|---|---|
| Middleware overhead per HTML request (p50 / p95) | — | 1.4 ms / 3.1 ms |
| HTML edge cache hit ratio | 94% | 0% for nonced pages, 94% elsewhere |
| TTFB p75 for nonced HTML | 120 ms | 210 ms (no edge cache) |
| Function invocations per month | 0 | 1.9 million (41 million before scoping) |
| Pricing A/B variants served correctly (sample of 2,000) | — | 100%, sticky |
The CSP nonce had a real cost: losing edge caching on HTML added about 90 ms of TTFB at p75. After measuring it, the team moved the third-party bootstrap into an external file and switched to hash-based CSP, restored edge caching, and kept middleware only for the internal section and the A/B test.
Pitfalls & Rollback
- Root middleware without
_routes.json. Every asset request invokes code and adds cost. - Caching per-request responses. Nonced, authenticated or cookie-varied HTML must be
private, no-store. - Buffering whole responses. Use
HTMLRewriteror pass bodies through to keep streaming. - Secrets in code. Use environment variables and secrets in the Pages project settings.
- Rollback: delete the middleware file or narrow
_routes.json; the next deploy serves static files directly again.
Conclusion
Pages Functions middleware gives a static site a few lines of edge logic exactly where they are needed. Place middleware in the directory it applies to, exclude assets with _routes.json, and be deliberate about caching, since per-request logic usually makes responses uncacheable. On this site, measuring the cost led to keeping middleware for authentication and one A/B test, and removing it where a static alternative existed.
FAQ
What is Pages Functions middleware?
A _middleware.js file in the functions directory of a Cloudflare Pages project. It runs before any function or static asset in its directory and below, receives the request, can modify or short-circuit it, and calls next() to continue to the static asset or a deeper function.
Does middleware run for static assets?
Yes, if the middleware file covers the path. A functions/_middleware.js at the root runs for every request, including images and scripts, which costs invocations and latency. Use _routes.json or place the middleware in a subdirectory to limit where it runs.
How much latency does middleware add?
For simple logic such as adding headers or checking a cookie, the measured overhead was 1 to 3 milliseconds at the edge. Middleware that fetches from an external API adds that API's latency, so cache such lookups or avoid them on hot paths.
Is middleware billed?
Yes. Requests that invoke a Pages Function, including middleware, count as Workers requests. Requests served only as static assets do not. Scoping middleware to the paths that need it keeps costs at zero for the rest of the site.
Related
- Parent: Cloudflare Pages Edge Caching Setup — caching and headers on Pages.
- Branch Deploy Controls on Cloudflare Pages — controlling which branches deploy.
- Writing a Content Security Policy for a Static Site — CSP without nonces.
- Feature Flags on Static Sites — alternatives to runtime A/B routing.
- Netlify Edge Functions for Static Sites — the Netlify equivalent.
Filed underHow-tosCloudflare