The answer. Everything under functions/ becomes one Worker at build time, routed by filename. functions/hello.js answers /hello; folders are path segments; [id].js captures a dynamic segment into context.params.id; a double-bracketed path.js captures the rest of the path. Export onRequest to catch every method or onRequestGet, onRequestPost and friends per verb. The handler receives one context object: request, env (bindings, not process.env), params, waitUntil, and next(). A _middleware.js file applies to its directory and everything beneath it, so a root middleware sees every request and decides whether to call next(), which falls through to the next function or, at the end, to the static asset. _routes.json narrows which paths invoke Functions at all, which is how you keep static traffic free.
The pattern.
functions/
_middleware.js -> runs on every request (auth, content negotiation, headers)
api/
images.js -> /api/images exports onRequestGet, onRequestPost
images/[id].js -> /api/images/:id context.params.id
mcp.js -> /mcp
// functions/api/images/[id].js
export async function onRequestGet({ env, params }) {
const row = await env.DB.prepare("select * from images where id = ?").bind(params.id).first();
return row ? Response.json(row) : new Response("not found", { status: 404 });
}
// functions/_middleware.js
export async function onRequest({ request, next }) {
const res = await next();
res.headers.set("x-frame-options", "DENY");
return res;
}
Watch out.
wrangler pages dev ./public(port 8788) is the only local mode that runs Functions; a framework dev server does not.- Bindings for Functions are configured in the Pages project (or a
wrangler.tomlwithpages_build_output_dir), and preview and production each need their own. - Frameworks (Next.js on Pages) compile their own route handlers to Functions and must run in the edge runtime.
Related: pages-middleware-accept-text-markdown-negotiation · mcp-server-as-pages-function-stateless-streamable-http · pages-vs-workers-static-assets-start-new-on-workers