Deploy a full-stack app with Pages + Functions
One git repo, one deploy: static pages and an /api/* backend ship together.
What are we wiring together?
Cloudflare Pages hosts your front-end (the HTML, CSS, and JavaScript a browser downloads). Pages Functions let that SAME project also run back-end code — your API. You drop files into a functions/ folder, each file becomes an API route, and you bind a D1 database (SQL) or KV store (key-value) so the back-end can read and write data.
The magic is that the static site and the API live in one repository and deploy together. A request to / returns a static file; a request to /api/hello runs a Function. No separate server, no separate deploy pipeline.
Think of it like…
A shop with a storefront and a back office under one roof. The storefront (static pages) is what customers see; the back office (Functions) handles orders and talks to the filing cabinet (D1/KV). One building, one set of keys, one address.
Who does what?
Here is the folder you actually create. Anything in your build output folder (here public/) is served as a static file. Anything in functions/ becomes server-side code mapped to a URL.
my-fullstack-app/
├── public/ # static front-end (served as-is)
│ └── index.html
├── functions/ # each file = one API route
│ └── api/
│ └── hello.js # -> /api/hello
├── schema.sql # D1 table + seed data
└── wrangler.toml # bindings + build output dirStatic front-end
Files in public/ (HTML, CSS, JS, images) are cached and served from Cloudflare's edge worldwide.
functions/ = your API
functions/api/hello.js automatically answers /api/hello. The file path becomes the URL path.
Bindings
A binding is a named handle (e.g. env.DB, env.CACHE) that connects a Function to D1 or KV — no connection strings.
One deploy
Push to git (or run one command) and the static site + Functions go live together at the same domain.
From git push to a live request
When you connect your repo, every push triggers a build. Pages compiles your front-end, bundles the functions/ folder, and deploys both. After that, the edge decides per-request: serve a static file, or run a Function.
Build it step by step
We'll create a tiny page that fetches /api/hello, a Function that reads a name from D1 and counts hits in KV, the bindings, and the deploy. Three layers — front-end, Function, data — all in one project.
Write the front-end
Put this in public/index.html. The fetch('/api/hello') is same-origin — no CORS, no full URL — because the API lives in the same project.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>Hello Pages</title> </head> <body> <h1 id="msg">Loading…</h1> <script> // Same origin: the Function is at /api/hello in the SAME project fetch("/api/hello") .then((r) => r.json()) .then((data) => { document.getElementById("msg").textContent = data.message; }) .catch((err) => { document.getElementById("msg").textContent = "Error: " + err; }); </script> </body> </html>Write the Function
Create functions/api/hello.js. The export name onRequest handles every HTTP method; context.env holds your bindings. (Use onRequestGet / onRequestPost to handle a single method.)
// functions/api/hello.js -> GET/POST /api/hello export async function onRequest(context) { // context.env carries your bindings (DB, CACHE) + vars + secrets const { env } = context; // Read one row from the D1 database bound as DB const { results } = await env.DB .prepare("SELECT name FROM greetings WHERE id = ?") .bind(1) .all(); const name = results[0]?.name ?? "world"; // Count visits in the KV namespace bound as CACHE const hits = Number(await env.CACHE.get("hits")) + 1; await env.CACHE.put("hits", String(hits)); return Response.json({ message: `Hello from ${name}!`, hits }); }Create the data layer
Make a D1 database and a KV namespace, then seed the table. Save the SQL below as schema.sql first.
-- schema.sql: create the table the Function reads, then seed one row CREATE TABLE IF NOT EXISTS greetings ( id INTEGER PRIMARY KEY, name TEXT NOT NULL ); INSERT INTO greetings (id, name) VALUES (1, 'Taipei');Provision D1 + KV
Wrangler prints an id for each — copy them into wrangler.toml in the next step. --remote runs against the real cloud database.
npx wrangler d1 create my-app-db npx wrangler kv namespace create CACHE # seed the D1 table from your SQL file npx wrangler d1 execute my-app-db --remote --file=./schema.sqlBind D1 + KV to the project
wrangler.toml tells Pages which storage to expose as env.DB and env.CACHE, and which folder is the static output. pages_build_output_dir is what makes this a Pages config.
name = "my-fullstack-app" pages_build_output_dir = "./public" compatibility_date = "2025-01-01" # D1 -> reachable in Functions as env.DB [[d1_databases]] binding = "DB" database_name = "my-app-db" database_id = "<paste-your-d1-id>" # KV -> reachable in Functions as env.CACHE [[kv_namespaces]] binding = "CACHE" id = "<paste-your-kv-id>"Configure the build
For plain HTML there's nothing to compile, so the build command is blank and the output is public/. For a framework (Vite/React) you'd set a build command and point the output at dist/. These live in the Pages dashboard under Settings > Builds, or in wrangler.toml.
# Pages build settings (dashboard, or wrangler.toml) Build command: (blank for plain static, or: npm run build) Build output directory: public # plain HTML/CSS/JS Root directory: / # For a Vite / React app instead: # Build command: npm run build # Build output directory: distDeploy two ways
Direct deploy uploads the folder right now. Git-connected is the real workflow: connect the repo once in the dashboard, set the build command + output dir, and every push to your production branch auto-builds and deploys.
# Option A: direct deploy (great for a quick first try) npx wrangler pages deploy ./public # Option B: git-connected (recommended) # 1. Push your repo to GitHub/GitLab # 2. Cloudflare dashboard > Workers & Pages > Create > Pages > Connect to Git # 3. Set build command + output dir, add D1/KV bindings # 4. Every push to main now builds & deploys automatically git push origin main
Key concepts
A common question: should I use Pages Functions or a standalone Worker? Quick rule below.
Pages vs Workers
Pages is front-end hosting with optional Functions bolted on; a Worker is pure code with no built-in static hosting. Pages Functions actually run ON Workers under the hood.
File = route
functions/api/hello.js answers /api/hello. [id].js matches one segment (e.g. /users/42); [[route]].js is a catch-all that matches any depth.
onRequest handlers
Export onRequest for all methods, or onRequestGet / onRequestPost / onRequestPut / onRequestDelete to handle just one HTTP verb.
The context object
Each handler gets context with env (bindings), params (dynamic route values), request, next (middleware), and waitUntil (background work).
Bindings, not secrets
Bindings give a Function direct, authenticated access to D1/KV/R2 via env.NAME. No connection strings or passwords in your code.
Middleware with _middleware.js
A _middleware.js in a folder runs before its routes — perfect for auth or logging. Call context.next() to continue to the matched Function.
Tips, gotchas & pricing
The functions/ folder is special
Don't put functions/ inside your build output folder. Pages reads functions/ separately to build routes; if your front-end build wipes or relocates it, your /api/* routes silently vanish.
- Bindings must be added for BOTH Production and Preview environments in the dashboard, or your preview deploys will throw 'undefined' on env.DB.
- Run the whole thing locally with: npx wrangler pages dev ./public — it serves static files and Functions together with your bindings.
- Function invocations count toward your Workers quota: the free plan includes 100,000 requests per day.
- Free plan: 500 builds per month, up to 20,000 files per deploy, and 25 MiB per asset.
- Use D1 for relational/SQL data, KV for fast key lookups and caching, and R2 for large files.
Start static, grow into full-stack
You can ship a pure static site today and add your first Function later just by creating functions/api/hello.js. Nothing else changes — same project, same deploy.
Related products
menu_bookOfficial docsopen_in_new