research/structuring-an-astro-site-with-pages-functions

Structuring an Astro Static Site with Cloudflare Pages Functions

Engineering Note · October 13, 2026 · 7 minute read · Astro · Cloudflare · Architecture

A static Astro site with Cloudflare Pages Functions is a static site plus a functions/ directory: Astro builds plain files, and each file under functions/ becomes one server route that runs only for the paths it owns. Most of what we build stays static. The trouble starts when one page needs a form that writes to a database, or an endpoint that sends email. The usual answer is to move everything onto a server framework. We keep the static site and add a small functions/ folder beside it. This note covers how we lay that out so it stays easy to reason about, and what we verified by running it.

Versions used for every command below: Node 22.22.2, wrangler 4.147.0, TypeScript 7.0.2 (with @cloudflare/workers-types for the handler types), and Astro 5.18.2 for the 404 check.

How do Astro and Pages Functions fit in one deploy?

The Astro half is plain static output. We build with Astro 5 and set build.format to 'file', so pages are emitted as files like /about.html rather than /about/index.html. Cloudflare Pages serves that output as it is.

The second half is the functions/ directory at the project root. Pages treats it as file-based routing: functions/api/contact.ts becomes /api/contact. Inside a file you export a handler, either onRequest for every method or a method-specific one such as onRequestPost. Static requests never touch a function, so pages stay as fast as static files while the few dynamic routes run as code.

Both halves ship from the same repository in the same deploy. There is no second service to version or keep in sync.

request
   |
   v
Pages router
   |-- path AND method match a route in functions/ --> handler runs --> Response
   |
   '-- anything else --> static assets (dist/) --> the file, or 404.html

How do you structure the functions folder?

Anything under functions/ becomes a route, with one exception: folders that start with an underscore are not routed. We put shared code in functions/_lib/, and it can be imported from any handler without becoming an endpoint.

The rule we follow is that each function stays small. A handler should read the request, call helpers, and return a response. Validation, sanitizing, response helpers and email code live in the shared lib. That keeps the route files readable and means a fix to input handling lands in one place instead of in every form.

The scratch project we ran had this layout:

functions/
  _lib/origin.ts
  api/contact.ts
  api/ping.ts
  api/health.ts
dist/
  index.html
  about.html
wrangler.jsonc

The contact handler uses PagesFunction, the type Cloudflare provides, with the generic parameter describing the bindings on env. It typechecks cleanly with npx tsc and runs under wrangler pages dev:

// functions/api/contact.ts
import { sameOrigin } from "../_lib/origin";

interface Env {
  GREETING: string;
}

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  if (!sameOrigin(request)) {
    return new Response("Forbidden", { status: 403 });
  }
  return Response.json({ ok: true, greeting: env.GREETING });
};

api/ping.ts exports a plain onRequest that echoes the method, so we could compare the two handler styles.

What does the router actually do? A routing table from a real run

We started npx wrangler pages dev dist and sent requests with curl. Status and responder for each:

RequestStatusWhat answered
GET /200static index.html
GET /about200static about.html
GET /about.html308redirect to /about
GET /api/ping200function (onRequest)
POST /api/ping200same function, any method
POST /api/contact, no Origin403function, rejected by the origin check
POST /api/contact, foreign Origin header403function, rejected
POST /api/contact, same-origin Origin200{"ok":true,"greeting":"hello from vars"}
GET /api/contact200static index.html (no onRequestGet exists)
GET /_lib/origin200static index.html, not the helper

The build step confirms the same thing. npx wrangler pages functions build --output-routes-path routes.json produced:

{
  "version": 1,
  "description": "Generated by wrangler@4.147.0",
  "include": [
    "/api/ping",
    "/api/contact"
  ],
  "exclude": []
}

Only the handler files are routes. _lib is absent, and everything not listed is served as a static file without invoking a function.

Failure modes we reproduced

A method you did not export falls through to static files. functions/api/contact.ts exports only onRequestPost, so GET /api/contact returned the home page with a 200. A client probing the endpoint with the wrong method gets HTML, not a 405. If that matters, export onRequest and switch on request.method yourself.

Unknown paths return the home page until you add a 404 page. With no 404.html in dist/, GET /nonexistent returned the index page with a 200. After adding dist/404.html, the same request returned 404 with that file as the body. In an Astro project, a src/pages/404.astro page builds to dist/404.html; we confirmed the file appears in the build output.

Helpers are unrouted, not guarded. /_lib/origin returns the home page because nothing is routed there. That is the behavior we want, but it means the underscore folder is a routing convention, not an access control. Never put a secret in a file just because it sits under _lib.

Why check the same origin on anything that costs money or writes data?

Any endpoint that sends email, writes to the database, or calls a paid API is something a stranger can try to hit directly. We reject requests that don’t come from our own pages. The check is small: compare the request’s Origin header with the origin of the request URL.

// functions/_lib/origin.ts
export function sameOrigin(request: Request): boolean {
  const origin = request.headers.get("Origin");
  return origin !== null && origin === new URL(request.url).origin;
}

The table above shows all three cases: a missing header and a foreign origin both return 403, and a matching origin passes. This is one layer, not a complete defense, because a non-browser client can set any header it likes. It sits alongside the bot check and the budget controls described in rate limiting an AI endpoint, and it costs almost nothing to include.

Where do bindings and secrets live?

Cloudflare Pages reads a wrangler.jsonc from the repository. We declare our bindings and non-secret variables there: the D1 database as DB, the R2 bucket as ATTACHMENTS. Because the file is committed, anyone reading the repo can see exactly which resources the code expects, and local development and production agree on the names.

This is the config we ran, with a placeholder database id. In local mode wrangler simulated both resources:

{
  "name": "example-site",
  "compatibility_date": "2025-01-01",
  "pages_build_output_dir": "dist",
  "vars": { "GREETING": "hello from vars" },
  "d1_databases": [
    { "binding": "DB", "database_name": "example-db", "database_id": "00000000-0000-0000-0000-000000000000" }
  ],
  "r2_buckets": [
    { "binding": "ATTACHMENTS", "bucket_name": "example-attachments" }
  ]
}

The startup banner listed the bindings, and a handler that ran select 1 against env.DB, then put and got an object through env.ATTACHMENTS, returned a 200:

env.DB (example-db)                    D1 Database           local
env.ATTACHMENTS (example-attachments)  R2 Bucket             local
env.GREETING ("hello from vars")       Environment Variable  local
{"d1":1,"r2":"hi"}

Secrets are the opposite. API keys live only in the Cloudflare dashboard and never appear in the repository, not even in an example file with real values. The code reads them from env like any other binding, so nothing in the handlers changes between local and deployed runs. We did not exercise a dashboard secret in this scratch run, so that part describes how we work, not something these commands prove. The split is easy to remember: if leaking it would hurt, it goes in the dashboard.

Checklist

  • Static output from Astro, with build.format set the way your links expect.
  • One file per route under functions/, each one short.
  • Shared helpers in an underscore-prefixed folder so they are never routed.
  • Export onRequest, or every method you care about, and ship a 404.html.
  • Bindings and plain variables in a committed wrangler.jsonc.
  • Secrets only in the dashboard.
  • A same-origin check on every endpoint that writes data or costs money.

None of this is clever, and that is the point. The directory layout is the architecture, which makes it easy to explain to the next person who opens the repo.

Frequently asked questions

Can I use Cloudflare Pages Functions with a static Astro site?

Yes. Build Astro to static files, set pages_build_output_dir to that folder, and add a functions/ directory at the project root. Every file in it becomes a route, such as functions/api/contact.ts at /api/contact. Requests for static files are served directly and never invoke a function, so both halves ship in one deploy.

How do I share code between Pages Functions without creating extra routes?

Put shared code in a folder whose name starts with an underscore, such as functions/_lib/. Pages does not route underscore-prefixed folders. We checked by building the scratch project: the generated route list contained only the two handler files, and a request to the helper's path fell through to static files.

What happens when a request uses a method my Pages Function does not export?

If you export only onRequestPost, a GET to that path is not handled by the function. In wrangler pages dev it fell through to the static asset layer and returned the site's index page with a 200 status. Export onRequest or the method handlers you want, and ship a 404.html if you want real 404s.

Where should API keys and bindings go in a Pages project?

Declare bindings, such as D1 and R2, and non-secret variables in a committed wrangler.jsonc so the repo shows what the code expects. Keep API keys only in the Cloudflare dashboard. Handlers read both from env, so the same code runs locally and deployed.

More from Research

← All research