Static sites don’t need heavyweight backends
There’s a reflex most developers have:
the moment data needs to be stored, a database enters the conversation.
PostgreSQL. MongoDB. Prisma. ORM. Hosting. Billing.
And for small static sites, this reflex is often wrong.
If your needs are simple — storing a bit of JSON, sharing state via URLs, persisting lightweight data — spinning up a “real backend” is unnecessary complexity.
This article shows a **simpler, cheaper, and cleaner alternative **using Cloudflare Workers and KV.
The real-world use case
I’m building a small collection of online tools hosted as a fully static site on Cloudflare Pages.
One of those tools is a Random Wheel.
I wanted users to be able to:
- save a list of values
- generate a short shareable URL
- reload the wheel later from that link
That’s it.
No auth. No relations. No queries. Just JSON in, JSON out.
Why a traditional database makes no sense here
Adding a real database would have meant:
- maintaining a backend
- handling deployment and secrets
- paying for infra that stays mostly idle
- increasing failure points
All of that just to store small arrays of strings.
This is exactly the kind of problem serverless edge tools were designed for.
The alternative: Workers + KV
The solution is a simple combination:
- Cloudflare Worker → acts as a tiny API
- KV Namespace → stores key-value data globally
What you get:
- no server to manage
- data available at the edge
- extremely low latency
- generous free tier
- perfect fit for static sites
You’re not replacing databases everywhere.
You’re not using them where they don’t belong.
Architecture overview (simple on purpose)
The flow looks like this:
- Browser sends data to a Worker
- Worker generates a short ID
- Data is stored in KV under that ID
- Later requests fetch the data using the same ID
Create a KV namespace
Using Wrangler (Cloudflare’s CLI):
npx wrangler kv:namespace create "APP_KV"
This creates a globally replicated key-value store you can bind to your Worker.
Bind KV in wrangler.toml
name = "static-site-api" # The name of your worker
main = "worker/index.ts" # The path to your worker's entry point
compatibility_date = "2024-05-12"
compatibility_flags = ["nodejs_compat"]
# KV Namespace binding
[[kv_namespaces]]
binding = "APP_KV"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
The binding name becomes important — it’s how your Worker accesses storage.
Install Worker dependencies
Inside a worker/ folder:
{
"name": "static-site-worker",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
},
"dependencies": {
"nanoid": "^5.0.7"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240512.0",
"wrangler": "^3.57.0"
}
}
Why this matters:
- nanoid → short, collision-resistant IDs
- Workers types → clean TypeScript support
- Wrangler → deploy & test easily
Write the Worker API
A minimal API with two endpoints:
- POST → store data
- GET → retrieve data
/// <reference types="@cloudflare/workers-types" />
import { nanoid } from 'nanoid';
export interface Env {
WHEEL_KV: KVNamespace;
}
const worker = {
async fetch(request: Request, env: Env): Promise<Response> {
const corsHeaders = {
'Access-Control-Allow-Origin': '*', // restrict in production
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
const url = new URL(request.url);
try {
switch (request.method) {
case 'POST': {
const options = await request.json<string[]>();
if (!Array.isArray(options) || options.length === 0) {
return new Response('Invalid payload', { status: 400 });
}
const id = nanoid(8);
await env.WHEEL_KV.put(id, JSON.stringify(options));
return new Response(JSON.stringify({ success: true, id }), {
status: 201,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
case 'GET': {
const id = url.searchParams.get('id');
if (!id) {
return new Response('Missing id', { status: 400 });
}
const data = await env.WHEEL_KV.get(id);
if (!data) {
return new Response('Not found', { status: 404 });
}
return new Response(data, {
status: 200,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
default:
return new Response('Method not allowed', { status: 405 });
}
} catch {
return new Response('Server error', { status: 500 });
}
},
};
export default worker;
Deploy it
From the root directory:
npx wrangler deploy
You’ll get a public URL like:
https://static-site-api.your-name.workers.dev
That URL is your backend.
Call it from your static frontend
From React / Next.js / vanilla JS:
const handleShare = async () => {
const options = ['Pizza', 'Burger', 'Tacos'];
const response = await fetch('https://static-site-api.your-name.workers.dev', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
const result = await response.json();
if (result.success) {
const shareableUrl = `https://yoursite.com/tool?id=${id}`;
navigator.clipboard.writeText(shareableUrl);
}
};
When this approach makes sense
Use this if:
- your site is static
- data is small and simple
- access patterns are predictable
- you don’t need complex queries
Do not use this if:
- you need strong consistency
- you’re building relational data
- writes are extremely frequent
- you need transactional guarantees
Top comments (0)