sellIntegration
web

Deploy a full-stack app with Pages + Functions

One git repo, one deploy: static pages and an /api/* backend ship together.

1Repo, full-stack
500Free builds / month
100Custom domains
25 MiBMax asset size
insights

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.

schemaProject layout & request routing

static path

/api/*

calls /api/hello

Browser

Cloudflare edge

Static assets (HTML/CSS/JS)

functions/api/[[route]].js

D1 database (env.DB)

KV namespace (env.CACHE)

home_work

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.

account_tree

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.

textFolder structure
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 dir
html

Static front-end

Files in public/ (HTML, CSS, JS, images) are cached and served from Cloudflare's edge worldwide.

function

functions/ = your API

functions/api/hello.js automatically answers /api/hello. The file path becomes the URL path.

cable

Bindings

A binding is a named handle (e.g. env.DB, env.CACHE) that connects a Function to D1 or KV — no connection strings.

merge

One deploy

Push to git (or run one command) and the static site + Functions go live together at the same domain.

swap_vert

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.

schemaPush, build, deploy, serve
Browser"Cloudflare edge""Pages build"GitHubDeveloperBrowser"Cloudflare edge""Pages build"GitHubDevelopergit pushwebhook triggers buildrun build commanddeploy assets + FunctionsGET /index.html (static)GET /api/helloJSON from Function
construction

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.

  1. 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.

    html
    <!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>
  2. 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.)

    js
    // 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 });
    }
  3. Create the data layer

    Make a D1 database and a KV namespace, then seed the table. Save the SQL below as schema.sql first.

    sql
    -- 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');
  4. 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.

    bash
    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.sql
  5. Bind 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.

    toml
    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>"
  6. 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.

    text
    # 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: dist
  7. Deploy 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.

    bash
    # 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
school

Key concepts

A common question: should I use Pages Functions or a standalone Worker? Quick rule below.

schemaPages Functions vs standalone Worker

Yes: front-end + API

No: API only

Need to host a website too?

Use Pages + Functions

Use a standalone Worker

One repo, one deploy

Pure backend service

compare_arrows

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.

route

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.

api

onRequest handlers

Export onRequest for all methods, or onRequestGet / onRequestPost / onRequestPut / onRequestDelete to handle just one HTTP verb.

deployed_code

The context object

Each handler gets context with env (bindings), params (dynamic route values), request, next (middleware), and waitUntil (background work).

vpn_key

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.

layers

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_and_updates

Tips, gotchas & pricing

report

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.
bolt

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.