← All writing
Hosting

From Static to Dynamic on Cloudflare Pages

A CONTACT FORM · A DATABASE · A DOMAIN · STILL $0.00

Static was the easy half.

Putting a folder of HTML on Cloudflare Pages takes five minutes. Then someone asks for a contact form, and suddenly you need code that runs on a server, somewhere to keep what people send, a secret that must never reach the browser, and a domain that is yours rather than a subdomain of someone else's.

The surprise is that none of that leaves the free tier. The other surprise is that the hard parts are not the code — they are four traps that cost me an evening each.

WHAT I HAD 3 THINGS HTML pages Stylesheets Images Files. Nothing runs. Nothing is remembered. WHAT I ADDED A function A database Two secrets My domain still $0 / month

This one is for developers. It assumes you have already put a static site on Cloudflare Pages and now need it to do something — take a form submission, remember it, and live on your own domain. Every number here comes from Cloudflare's own documentation, checked in September 2026.

The wrong turn I took first

I want to start with the mistake, because it is the one thing a tutorial will not tell you and it wasted the most time.

Cloudflare’s dashboard has a big blue Create application button. In 2026 that button walks you into Workers Builds — connect your GitHub repo, set a build command, done. It looks like the obvious front door. I clicked it, connected my repository, and got this:

Build #582f8e09✕ failed
Build commandNone
Deploy commandnpx wrangler deploy
Root directory/site

Three things are wrong in that little box, and they are worth naming one by one.

wrangler deploy is the Workers command, not the Pages command. They are different products with different deploy verbs. A Pages project is deployed with wrangler pages deploy <directory>. Running the Workers verb against a Pages configuration gets you an error that reads like a bug in your code.

The build command was empty. The log went straight from npm clean-install to the deploy step, so there was no built output to deploy at all. It was shipping nothing, loudly.

It was not even the right project. Cloudflare created a new application from my repository name. My real site lived in a Pages project called something else entirely, with the database and the secrets attached to it. Even a green build would have deployed a stranger.

A successful deploy to the wrong project is worse than a failed one.

I deleted the whole thing and went back to deploying from my laptop. Which, it turns out, is the better answer anyway — for a reason that has nothing to do with Workers versus Pages.

WHY I DO NOT CONNECT GIT

My website lives inside a larger private repository that also holds job applications and cover letters. Connecting a Git repository hands the whole repository to the build system. Deploying by direct upload sends only the built output folder — so the rest of the repo physically cannot travel. If your repo is a clean single-purpose one, Git connection is genuinely convenient. Mine is not, and "the private files cannot leak because they are never sent" beats any amount of careful configuration.

Here is what a correctly configured project looks like afterwards. Note the line under the name:

Workers & PagesCreate application
nuhin-dev8h ago
nuhin-dev.pages.dev
⑂ No Git connection

“No Git connection” is not a warning. On a direct-upload project it is the design.

The shape of a dynamic Pages site

Before the how, the what. A Pages site with a backend has exactly two kinds of request, and Cloudflare decides between them by looking at the path.

Browser someone's phone /about /api/* THE NEAREST CITY Static files already sitting there no code runs Your function functions/api/contact.js starts on request stops right after D1 database SQLite, on Cloudflare this is the memory Outside services notifications, email Secrets held by Cloudflare, never in your files
Two paths, one project. The purple path never runs code — the file is already at the city nearest the visitor. The orange path starts your function, which reads its secrets from Cloudflare and its data from D1, then stops. You are billed for the orange path and not the purple one, which is why a busy blog on Cloudflare Pages still costs nothing.

The mental shift that took me longest: the function does not stay running between requests. There is no process holding a variable. I originally kept a rate-limit counter in a JavaScript Map, which worked perfectly on my laptop and did nothing at all in production, because every request got a fresh, empty Map. Anything that must be remembered between two requests has to go into the database. That is not a limitation to work around; it is the entire model.

Adding a database with D1

D1 is Cloudflare’s SQLite database. It is real SQL — tables, indexes, JOIN, transactions — and on the free plan it costs nothing.

1

Create it

One command. It prints a UUID, which is the database's id.

npx wrangler d1 create my-leads
2

Bind it to the project

A binding is how your code gets a handle on the database. It goes in wrangler.toml next to your Pages configuration:

name = "my-site"
pages_build_output_dir = "dist"
[[d1_databases]]
binding = "DB"
database_name = "my-leads"
database_id = "the-uuid-from-step-1"

That id is a name, not a password — it is safe to commit. The credentials that are secret come later.

3

Create the tables

Write the schema as an ordinary .sql file and apply it. --local hits the copy on your machine, --remote hits the real one.

npx wrangler d1 execute my-leads --local  --file=./schema.sql
npx wrangler d1 execute my-leads --remote --file=./schema.sql

You will forget --remote at least once and wonder why production has no tables. Everyone does.

4

Use it

The binding arrives as env.DB. Always use bound parameters — never build SQL by joining strings.

const result = await env.DB
  .prepare('INSERT INTO leads (name, email, message) VALUES (?, ?, ?)')
  .bind(lead.name, lead.email, lead.message)
  .run();
const id = result.meta.last_row_id;
5

Read it back

You do not need a GUI. The same command runs any query against the live database:

npx wrangler d1 execute my-leads --remote \
  --command "SELECT * FROM leads ORDER BY id DESC LIMIT 20"

I wrapped that in a one-word script so checking enquiries is ./run leads --remote. A five-line wrapper you will use weekly is worth more than an afternoon of dashboard-clicking.

What the dashboard tells you

The D1 overview page is one of the more useful screens Cloudflare has, because it answers “am I anywhere near the limits” at a glance. Mine, after a day of real traffic:

D1 Database › my-leadsLast 24 hours
Total queries
20
Rows read
73
Rows written
6
Storage used
32.77 kB
Tables
2

Now hold those against the free plan, which as of September 2026 allows 5 million rows read per day and 100,000 rows written per day.

Rows read · one day
73of 5,000,000 allowed
0.0015% of the allowance. I would need roughly 68,000 times the traffic to reach the ceiling.
Rows written · one day
6of 100,000 allowed
0.006% of the allowance. Writes are the scarcer resource — this is the number to watch, not reads.
Storage · total
32.77kB of a 500 MB cap
The free per-database cap is 500 MB, inside 5 GB across the whole account.

The point of showing my own unimpressive numbers is calibration. A portfolio contact form, a small dashboard, a side project with a few hundred daily visitors — these do not approach a free D1 allowance. The free tier is not a teaser that runs out the moment anyone visits. What does burn rows is a query pattern that reads a whole table on every page load; the fix there is an index, not a bigger plan.

FREE UNDO — TIME TRAVEL

D1 keeps a point-in-time history of your database: 7 days on the free plan, 30 on paid. If you run the wrong DELETE against production — and one day you will — you can restore to a timestamp before it happened. It is on by default and costs nothing extra. Knowing it exists before you need it is the whole trick.

Variables and secrets

This is where I nearly shipped something embarrassing, so it gets a section of its own.

There are two kinds of configuration on Pages and they behave nothing alike. Getting them the wrong way round is either a broken site or a leaked credential.

BUILD-TIME VARIABLE Baked into the page PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA… Ends up in the HTML the browser downloads. Anyone can read it with View Source. That is fine — it is meant to be public. RUNTIME SECRET Never leaves Cloudflare wrangler pages secret put API_TOKEN Encrypted. Readable only by your function, as env.API_TOKEN. Not in the repo. Not in the HTML. Not readable again, even by you.
The test is simple: if the browser needs it, it is public — no matter what you call it. A "secret" read by client-side JavaScript was never secret. Turnstile makes this explicit by handing you a pair: a site key for the page and a secret key for the server.

Set a secret once, from the terminal:

npx wrangler pages secret put TELEGRAM_BOT_TOKEN --project-name my-site
npx wrangler pages secret put TURNSTILE_SECRET_KEY --project-name my-site

It prompts, you paste, it is encrypted and stored. You cannot read it back afterwards — not through the CLI, not in the dashboard. That is the feature. Lose it and you rotate it; there is no recovery, and there does not need to be.

Public build-time values go in a .env file instead, and by convention are prefixed so nobody is confused about what they are:

PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA...
THE ONE THAT ALMOST GOT ME

Test keys pass every check, including a bot's

Turnstile ships an always-passes test key so a fresh checkout runs without credentials. It is a genuinely good idea, and it is a landmine: a form protected by the test key looks completely normal, submits successfully, shows a tick — and stops precisely nobody.

A broken bot filter announces itself. A test key does not. You find out weeks later, from the spam.

The fix: make deployment refuse to run. My deploy script greps .env for the known test key and exits before uploading if it finds it. Ten lines. It has already stopped me once, on a tired evening when I would certainly have shipped it.

Updating the site

Once the wiring is done, this is the whole workflow — and it is worth designing it to be one command, because a deploy you have to think about is a deploy you will do badly at 1am.

./run deploy

Behind that, in order:

Preflight logged in? real keys? Test unit tests type check Build on my laptop → dist/ Upload only dist/ + functions/ changed files only Live everywhere a new immutable version, kept forever the old one is still one click away ONE COMMAND · ABOUT 30 SECONDS Nothing is uploaded unless the tests pass — which is the only reason I trust deploying on a Friday.
Two details make this pleasant in practice. Only changed files are uploaded, so the second deploy of the day is far quicker than the first. And every deploy is kept as its own version — rolling back a bad release is selecting the previous deployment in the dashboard, not a frantic revert-and-rebuild.

Updating the database is a separate motion, and deliberately so. Schema changes ship as SQL files applied with wrangler d1 execute --remote. Keeping them out of the deploy path is a feature: you almost never want “push the site” and “alter a live table” to be the same button.

Pointing your own domain at it

Every project gets a free <name>.pages.dev address and a working certificate immediately. Attaching your own domain takes about a minute.

In the dashboard: Workers & Pages → your project → Custom domains → Set up a domain.

What happens next depends on where your DNS lives.

Domain already on Cloudflare
  • Confirm the record and Cloudflare adds the CNAME itself.
  • Apex domains work — Cloudflare flattens the CNAME for you, which ordinary DNS cannot do.
  • The certificate is issued automatically.
Domain registered elsewhere
  • A subdomain like www just needs a CNAME to <project>.pages.dev.
  • For the bare apex you must point your nameservers at Cloudflare.
  • Check for a CAA record that blocks Cloudflare from issuing certificates — a rare problem with a confusing error.

Two things worth knowing afterwards. The pages.dev address keeps working — it is not replaced, and it stays useful as a way to check a deployment without touching the real domain. And the certificate covers your domain automatically, with renewal handled for you; there is no expiry date to diary.

Four traps that cost me time

TRAP 1

Deploying to a project that is not yours

The Create-application wizard names the new project after your repository. If your real project is called something else, you now have two: a working one nobody is deploying to, and a broken one collecting failed builds.

The fix: always pass --project-name explicitly, and check the list in the dashboard before assuming a green deploy went where you think it did.

TRAP 2

Your deploy lands on a preview URL and production never changes

Pages compares the branch you deployed from against the project's production branch. If they differ, you get a preview deployment — a real, working, correctly built site on a URL nobody visits. The deploy succeeds. The site does not change. There is no warning anywhere.

The fix: set the production branch to the branch you actually deploy from when you create the project, and read the URL in the deploy output rather than assuming.

TRAP 3

Generated files are in .gitignore, and a Git build cannot generate them

This is the one that decides the whole architecture. My build needs files that are produced by a script — generated JSON, images copied from elsewhere in the repo — and those are gitignored, because committing generated output is how two copies drift apart. Building on my laptop, the script runs first and everything is there. Building from a Git clone, the files simply do not exist and the build fails on a missing import.

The fix: either commit the generated output, or install the generator in the build image, or build locally and upload the result. I chose the third because it also keeps a private repository private.

TRAP 4

The silent notification failure

My function saves the enquiry first and sends the notification second, on purpose — a stored message beats a delivered one. The consequence is that if notifications break, the form still says "sent", because it was: the row is in the database. Nothing is lost, but nothing tells you either. I found out because a delivery flag in the table was 0.

The fix: a command that sends one real test notification end to end, run before you trust it and after any change. Mine also verifies the escaping by sending a message full of reserved characters — a failure mode I would never have caught by hand.

What it actually costs

Every figure below is from Cloudflare’s own documentation, as of September 2026. The terms do move, so check them rather than trusting a blog post — including this one.

WhatFree planWhat that means in practice
Static requestsUnlimitedPages, images, stylesheets. Traffic on the static side is genuinely uncapped.
Function requests100,000 / dayOnly /api/* style calls count. Form submissions, not page views.
CPU per invocation10 msAmple for a database write and an HTTP call. Not for image processing.
D1 rows read5,000,000 / dayEffectively unreachable for a small site with sane queries.
D1 rows written100,000 / dayThe scarcer of the two. Watch this one first.
D1 storage500 MB per database, 5 GB totalUp to 10 databases on the free plan.
D1 Time Travel7 daysPoint-in-time restore after a bad query. On by default.
Queries per invocation50A per-request budget. Loops issuing one query each will find it.
Files per deployment20,000Generous unless you ship a large image gallery.
Max file size25 MiBVideo belongs somewhere else regardless.
Custom domains100 per projectCertificates included and renewed for you.

One line worth adding, because it surprised me: Cloudflare’s free plan includes 500 builds per month — but that quota is for builds Cloudflare runs. Build on your own machine and upload the result and you never touch it. Direct upload is not only the safer path for a private repository; it is the one with no build budget at all.

Where to read more

One last thing

The thing I did not expect was how much of this was deciding, not building. The code for a contact form is an afternoon. Choosing direct upload over a Git connection, deciding that the database write comes before the notification, making the deploy script refuse to ship a test key — those took longer and mattered more.

Every one of them came from the same question: when this breaks, how will I find out? A Git build that deploys the wrong project, a preview URL that looks like production, a bot filter that passes everything, a notification that silently stops — all four failed quietly. None of them threw an error. That is what made them expensive.

So the practical advice, more than any command in this post: after you wire something up, break it on purpose and check that you notice. If nothing tells you, add the thing that will.

—claps

Worth something? Tap it — up to twenty times. It is anonymous, and the total is the only thing stored.

Related reading

Building something that needs this kind of engineer?

Hiring, contract, or an architecture problem — tell me the shape of it and I’ll come back with something specific.

Contact me →