systemref

Building Data Apps using Cloudflare Workers, and Notion Dabatases

H. Maqsood Jun 7, 2026 2 min read cloudflare notion serverless data
Cloudflare Workers at the edge plus Notion as a managed database gives you a full read/write data layer — no infrastructure to operate, no friction to start.

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.