Cloudflare Workers execute at the edge — code runs in the region nearest to the request, not in a fixed data center. P90 latency is typically under 100ms. Notion databases give non-technical operators a UI to manage records while exposing the same data over a REST API. Together they form a data app stack you can ship in an afternoon and scale without ops overhead.
The stack
| Layer | Role |
|---|---|
| Cloudflare Workers | HTTP routing, business logic, response transformation |
| Notion Database | Persistent store, admin UI |
| Cloudflare KV | Response cache — absorbs Notion's 3 req/s rate limit |
Querying the database
Every row in a Notion database is a page. The /databases/:id/query endpoint returns an array of page objects, each with a properties field keyed by column name.
// src/notion.js
export async function queryDatabase(dbId, token, filter = {}) {
const res = await fetch(`https://api.notion.com/v1/databases/${dbId}/query`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Notion-Version': '2022-06-28',
'Content-Type': 'application/json',
},
body: JSON.stringify(filter),
});
return res.json();
}
Property shapes vary by type. Title, rich text, and checkbox each have a different structure. Extract explicitly — don't assume shape.
function extractTitle(page) {
return page.properties.Name?.title[0]?.plain_text ?? '';
}
function extractPublished(page) {
return page.properties.published?.checkbox ?? false;
}
Caching with KV
Notion's free plan enforces a 3 req/s rate limit across your integration. Cache every query result in KV. A TTL of 300 seconds absorbs traffic spikes without serving stale data for long.
async function getCached(key, fetcher, env, ttl = 300) {
const cached = await env.KV.get(key);
if (cached) return JSON.parse(cached);
const data = await fetcher();
await env.KV.put(key, JSON.stringify(data), { expirationTtl: ttl });
return data;
}
Bust the cache on write by deleting the key:
await env.KV.delete('index');
Routing
A minimal Worker that serves a Notion-backed JSON API:
// src/index.js
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/api/articles') {
const data = await getCached(
'index',
() => queryDatabase(env.NOTION_DB_ID, env.NOTION_TOKEN),
env
);
return Response.json(data);
}
return new Response('Not found', { status: 404 });
}
};
Set NOTION_TOKEN as a secret, never in wrangler.toml. Set NOTION_DB_ID as a plain var — it's not sensitive.
# wrangler.toml
[vars]
NOTION_DB_ID = "your-db-id-here"
[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"
When to use this
This stack fits content-heavy apps with a flat or lightly relational data model — articles, products, tasks, events. It suits teams where a non-engineer needs to own the data, and where you don't want to operate a database.
Don't use it for write-heavy workloads, relational joins, high-cardinality queries, or any case where you need transactional guarantees. Notion is a CMS, not a relational database. Treat it accordingly.