Skip to content
12 March 2026 · 12 min read

Raveo: how I built my own Cloudflare stack for client websites.

I wanted a foundation I could build multiple projects from. No traditional server, client-managed content, global reach. Here is how it all works.

#Cloudflare#Astro#PayloadCMS#Workers#edge

The problem with building websites on commission is that you start from scratch every time. Pick a CMS, set up hosting, figure out how to get content to the frontend, sort out caching. Then do the same thing again next time, just slightly differently.

I wanted my own foundation: a monorepo with CMS and frontend, deployable with a single command, with content the client manages themselves. No traditional server. That is how Raveo came together.

What Raveo is

Raveo is a monorepo with two Cloudflare Workers. One runs PayloadCMS on Next.js as a headless CMS. The other runs an Astro frontend. Everything else is Cloudflare services: D1 as the database, R2 for media, KV for cache.

raveo/
├── apps/
│   ├── cms/     PayloadCMS + Next.js → Cloudflare Worker
│   └── web/     Astro v6 → Cloudflare Worker
└── packages/
    ├── types/   generated types from PayloadCMS schema
    ├── ui/      shared components + Lexical renderer
    └── config/  shared tsconfig + Biome configuration

Turborepo manages the monorepo with a pnpm workspace. Deploying both workers at once takes one command.

Cloudflare primitives: what each piece does

Before the implementation, a quick explanation of what each service actually is — it is not obvious from the names.

Workers are V8 isolates — small isolated environments that run code on each request. They do not start as a traditional server sitting in memory waiting. They spin up on demand, handle the request and disappear. No cold start, global network, you pay per real request not per uptime.

D1 is serverless SQLite directly on the Cloudflare edge. No PostgreSQL server, no managed database. A SQLite file replicated globally, accessible via a Workers binding. For a CMS database that does not need millions of concurrent writes per second, it is a good fit.

R2 is object storage compatible with the S3 API. Media files, images, documents. No egress fees for downloads — a meaningful difference from standard S3 when traffic picks up.

KV is a distributed key-value store with very low read latency. Good for caching: write once, read many times. With TTL expiration built in.

PayloadCMS on Workers: D1 and R2 adapters

PayloadCMS normally expects a Node.js server and PostgreSQL or MongoDB. Cloudflare Workers are a runtime without Node.js and without TCP access to external databases. Adapters solve this.

In payload.config.ts the configuration looks like this:

export default buildConfig({
  collections: [Users, Media, Categories, Posts, Pages, Forms, FormSubmissions],
  globals: [Navigation, SiteSettings],
  editor: lexicalEditor(),

  // D1 instead of PostgreSQL
  db: sqliteD1Adapter({
    binding: cloudflare.env.D1,
    push: !isSeed,
  }),

  // R2 instead of local filesystem or S3
  plugins: [
    r2Storage({
      bucket: cloudflare.env.R2,
      collections: { media: true },
    }),
  ],
});

The D1 binding is direct database access without a network hop. The R2 binding is direct object storage access. Everything goes through internal Cloudflare channels, not the public internet.

The result: the PayloadCMS admin interface runs as a Worker. The client logs in, manages content, saves changes. Database is D1, media goes into R2.

Service Bindings: how the workers talk to each other

Two workers need to communicate. The naive solution is calling the CMS over HTTP, but that adds latency, a DNS lookup and an extra network hop.

Cloudflare has Service Bindings for this. A direct connection between Workers without HTTP, without DNS, with zero latency. Worker A calls Worker B like a function, not an HTTP endpoint.

In the middleware it looks like this:

async function fetchFromCMS(fetcher: Fetcher | null, cmsUrl: string, path: string) {
  const url = `https://cms${path}`;
  const fallbackUrl = `${cmsUrl}${path}`;

  const res = fetcher
    ? await fetcher.fetch(url)   // production: service binding, zero latency
    : await fetch(fallbackUrl);  // dev: HTTP to localhost:3000

  if (!res.ok) return null;
  return await res.json();
}

fetcher is the Service Binding — available in production via env.CMS. In local development it is not available, so it falls back to HTTP. Switching is automatic.

Middleware and KV cache

Astro middleware runs on every request. Its job is to load data from the CMS and pass it to pages via locals. Calling the CMS on every request would be unnecessarily slow.

So there is a KV cache in between:

async function cachedFetch(fetcher, cmsUrl, path, cache) {
  // 1. Try KV cache
  if (cache) {
    const cached = await cache.get(path, 'json');
    if (cached) return cached;
  }

  // 2. Cache miss — call CMS
  const data = await fetchFromCMS(fetcher, cmsUrl, path);

  // 3. Write to KV for 5 minutes (fire-and-forget, does not block response)
  if (data && cache) {
    cache.put(path, JSON.stringify(data), { expirationTtl: 300 }).catch(() => {});
  }

  return data;
}

The key detail is the fire-and-forget KV write. The response to the user does not wait for the cache write to complete. The write happens in the background.

On every request four things are fetched in parallel:

const [navigation, siteSettings, pagesData, postsData] = await Promise.all([
  cachedFetch(fetcher, cmsUrl, '/api/globals/navigation?depth=1', cache),
  cachedFetch(fetcher, cmsUrl, '/api/globals/site-settings?depth=1', cache),
  cachedFetch(fetcher, cmsUrl, '/api/pages?depth=2&limit=100&where[status][equals]=published', cache),
  cachedFetch(fetcher, cmsUrl, '/api/posts?depth=2&limit=100&where[status][equals]=published', cache),
]);

Promise.all matters here — all four calls go in parallel, not in sequence.

Content revalidation

The KV cache has a five-minute TTL. But what if the client saves a change and wants to see it immediately?

Every collection in the CMS has an afterChange hook. When content is saved, the hook calls the Web Worker and tells it to invalidate the cache:

const revalidate = async () => {
  if (process.env.NODE_ENV === 'production') {
    // Service binding: CMS Worker calls Web Worker directly
    await cfEnv.WEB.fetch(
      new Request('https://web/api/revalidate', {
        method: 'POST',
        headers: { 'x-revalidate-secret': cfEnv.REVALIDATE_SECRET ?? '' },
      }),
    );
  } else {
    await fetch(`${webUrl}/api/revalidate`, { method: 'POST', ... });
  }
};

export const revalidateAfterChange: CollectionAfterChangeHook = async ({ doc }) => {
  await revalidate();
  return doc;
};

After revalidation the KV cache expires and the next request fetches fresh data from the CMS. No rebuild pipeline, no waiting on a deploy.

Lexical renderer: JSON to HTML without JavaScript

PayloadCMS stores rich text in Lexical JSON format — a tree of nodes. Ready-made libraries handle this through React and client-side JavaScript. But Astro pages are static and I do not want client-side JavaScript for this. So I wrote a custom server-side renderer.

The interesting part is text formatting. Lexical stores format as a bitmask — a number where each bit represents a different style:

function renderTextFormat(text: string, format: number): string {
  let result = escapeHtml(text);
  if (format & 16) result = `<code>${result}</code>`;    // inline code
  if (format & 1)  result = `<strong>${result}</strong>`; // bold
  if (format & 2)  result = `<em>${result}</em>`;         // italic
  if (format & 8)  result = `<u>${result}</u>`;           // underline
  if (format & 4)  result = `<s>${result}</s>`;           // strikethrough
  if (format & 32) result = `<sub>${result}</sub>`;       // subscript
  if (format & 64) result = `<sup>${result}</sup>`;       // superscript
}

The bitwise AND (&) checks whether a given bit is set. Text can be bold and italic at the same time — both bits are set simultaneously. The renderer walks the full tree recursively: paragraphs, headings, blockquotes, lists, checkboxes, links, images. Output is clean HTML with no client-side JavaScript.

Rate limiting on KV

I did not want to bring in an external service for rate limiting. The KV namespace for cache is already there, so I used it for rate limiting too.

The implementation is a sliding window: for each IP address it stores a request count and the start of the current time window:

interface RateLimitEntry {
  count: number;
  windowStart: number;
}

Key design decision: the rate limiter fails open — if KV is unavailable, the request goes through. The alternative would be to reject the request, but a KV outage would then take down the entire site. Fail open is the right call here.

POST requests are limited to 10 per minute, API endpoints to 30. Responses include RFC-standard RateLimit-Remaining and Retry-After headers.

Where it is now and where it is going

I am building the website for my Scout troop Prácheň on Raveo. It will be the first real production deployment of the stack.

Down the road I am interested in integrating Medusa.js as a transactional backend. That would make Raveo a base for e-commerce builds on commission: PayloadCMS for content management, Medusa for products and orders, Astro for the frontend. All without a traditional server, globally distributed, with content the client manages themselves.

The full project is open source at github.com/raveo-dev/raveo.