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];.

Order of middleware and static assets for a request in Pages Functions A request for a page under /internal passes first through the root middleware if one exists, then the internal directory middleware which checks basic authentication, then reaches the static asset. Requests excluded by _routes.json skip all functions and are served directly as static assets. A request through scoped middleware _routes.json filter exclude assets and most pages excluded paths never invoke code functions/_middleware.js CSP nonce on HTML only runs for included paths functions/internal/_middleware.js basic auth runs for /internal/* only Static asset context.next() served from the edge
Directory placement and _routes.json together decide which requests pay for code.

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.

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.

Sequence of the cookie-based A/B split in middleware A first-time reader requests /pricing/ with no cookie. The middleware picks variant B at random, fetches the static /pricing/b/ page through env.ASSETS and returns it at the /pricing/ URL with a Set-Cookie header. On a later visit the browser sends the ab_pricing cookie and the middleware serves the same variant without choosing again. Assigning and keeping a pricing variant Browser reader Middleware functions/pricing Static assets env.ASSETS GET /pricing/ (no cookie) pick b, fetch /pricing/b/ page B + Set-Cookie ab_pricing=b later: GET /pricing/ with cookie fetch the same variant
The variant is a rewrite, not a redirect, so the URL and analytics page path stay the same.

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.

Invocations per page view before and after scoping middleware Vertical bars. With an unscoped root middleware, one page view invoked the function 23 times, once for the HTML and once for each asset. After excluding assets with _routes.json, a page view invoked it once. Monthly invocations fell from 41 million to 1.9 million. Function invocations per page view 23 Unscoped root middleware 1 Scoped with _routes.json
Cloudflare analytics for the Pages project over 30 days; traffic similar in both periods.

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

MeasureStatic onlyWith scoped middleware
Middleware overhead per HTML request (p50 / p95)—1.4 ms / 3.1 ms
HTML edge cache hit ratio94%0% for nonced pages, 94% elsewhere
TTFB p75 for nonced HTML120 ms210 ms (no edge cache)
Function invocations per month01.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 HTMLRewriter or 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.

Filed underHow-tosCloudflare