Lorenly is a booking platform for salons and other appointment businesses. Every business on it gets a public website: their services, their prices, their team, a gallery, opening hours and a booking button. Some use a subdomain we give them. Others bring their own domain.
For a long time, each of those websites was its own deployment. One build per business, its own environment variables, its own domain mapping. That works when you have five customers. It stops working well somewhere after that, and we could see where.
This post is about moving every tenant site onto one Next.js app running as one Cloudflare Worker, built with OpenNext. It covers how the Worker works out which business a request is for, how custom domains work without Cloudflare for SaaS, why we had to write our own edge cache, and how an edit in the dashboard reaches a cached site in about thirty seconds.
Why one deployment per tenant had to go
Our planning document had a table comparing the two designs, and a few lines of it made the decision for us.
With one deployment per tenant, each site used roughly 150 to 350 MB of memory when we self-hosted it. That put a ceiling of about forty or fifty sites on the machine we had. A code change meant rebuilding and redeploying every site. Onboarding a new business meant minting a key, creating a project, setting around twenty environment variables, building, deploying and mapping a domain. That's minutes of work per tenant, and every step was a place for something to go wrong.
With one shared deployment, memory doesn't grow with the number of tenants. A code change is one build and one deploy. And onboarding becomes a few database inserts and one call to the Cloudflare API. Our provisioner does that in milliseconds instead of a build that takes minutes.
The plan was honest about the cost too. With one deployment per tenant, a broken site breaks one tenant. With a shared deployment, a broken deploy breaks all of them, and a bug in tenant isolation could show one business's page to another business's visitors. Most of the work was making sure that second thing can't happen.
The real obstacle: identity baked in at build time
The hard part wasn't Cloudflare. It was Next.js.
In the old setup, a site's identity was fixed when it was built. The site ID was a module-level constant read from an environment variable, and every data function filtered by it. The site's name, tagline, theme, contact details and social links came from about two dozen NEXT_PUBLIC_* variables. And NEXT_PUBLIC_* variables are inlined into the browser bundle at build time. A single build literally couldn't render two different brands in the browser.
So the migration was really one sentence long: move site identity from build-time environment to runtime, resolved for each request from the Host header. Doing it touched dozens of data functions, server pages and client components. Server code now asks for the current site's config per request. Client components read it from a React context that the layout fills in. Nothing reads identity from the environment any more.
We kept the old mode working as a fallback. If the build has a fixed site ID, it behaves like a single-tenant site, exactly as before. If not, it resolves the tenant from the host, and fails closed if it can't.
From host to tenant
A site_domains table maps hostnames to sites. Each site has one primary host and can have aliases. The Worker looks the host up on each request, and keeps the answers in a small in-memory cache inside the isolate:
const DOMAIN_TTL_MS = 5 * 60 * 1000; // a known host: re-check every 5 minutes
const DOMAIN_MISS_TTL_MS = 15 * 1000; // an unknown host: re-check after 15 seconds
const DOMAIN_CACHE_MAX = 2000; // past this, start over rather than grow
The numbers are chosen for what each answer means. A host that maps to a site almost never changes, so five minutes is fine. A host that maps to nothing might be a business that's being set up right now, so we only remember a miss for fifteen seconds. Nobody should wait five minutes for their new site to appear because a request arrived a moment too early.
There's one more rule in the lookup that's easy to get wrong. If the database call itself fails, the Worker doesn't cache anything. It treats the failure as "I don't know", not as "this host has no site". Caching a failed lookup as a miss would turn a few seconds of database trouble into fifteen seconds of sites returning 404.
When the host is an alias rather than the primary host, the Worker redirects to the primary. That keeps search engines from indexing the same salon twice, and it means we only ever render and cache one copy of each page.
Custom domains without Cloudflare for SaaS
Cloudflare has a product for exactly this situation, Cloudflare for SaaS, which lets your customers point their own domains at your app. We don't use it. We use plain Workers Custom Domains and a different way of getting the customer's domain into our account.
For our own subdomains, it's simple. When a business signs up, the provisioner attaches its hostname to the shared Worker through the Workers Custom Domains API. Cloudflare creates the DNS record and the certificate. The provisioner runs as a sweep about every five minutes.
For a business that brings its own domain, we use nameserver delegation. The business adds its domain in our dashboard. We create a zone for it in our Cloudflare account, and Cloudflare imports the domain's existing DNS records, email included. We show the business two nameservers. They change their nameservers at their registrar. Our dashboard checks until Cloudflare reports the zone as active, then attaches the domain to the shared Worker, adds a basic firewall rule, and maps the host to the site.
This has a real downside: the business has to change nameservers, which is a bigger ask than adding one CNAME record, and it moves their whole DNS into our account. In exchange it needs no paid add-on, Cloudflare carries their existing records over, and the instruction is short: replace these two values with these two values.
Removing a domain is the same in reverse: detach the custom domain from the Worker, then clean up.
Why we had to build our own cache
Here's the problem we didn't see coming, and it's the most important part of this post if you're building something similar.
A Worker runs in front of Cloudflare's cache, not behind it. When a Worker renders a page, the response doesn't go through the zone's cache rules. Every visit to every page was a full server-side render, and a Next.js render on our pages costs between 250 ms and a full second of CPU.
The obvious fix would be Next.js's own incremental static regeneration. We couldn't use it. One Worker serves every tenant from the same routes, and the brand comes from the Host header. Next keys its cache by path alone. Two salons both have a /services page, and with ISR, the first one rendered would be served to visitors of the second. That's not a performance bug. That's showing one business's prices on another business's site.
So the Worker caches explicitly, using the Cache API, with the tenant in the key. The request URL already includes the host, and we add the site ID on top so the key can't collide even if two hosts ever resolved strangely:
function cacheKeyFor(siteId, request) {
const keyUrl = normalizedPublicCacheUrl(request); // strips tracking params, keeps only bounded ones
if (!keyUrl) throw new Error("uncacheable query reached cacheKeyFor");
keyUrl.searchParams.append("__salon_cache_site", siteId);
return new Request(keyUrl.toString(), { method: "GET", headers: { accept: "text/html" } });
}
Deciding what's safe to cache took more care than the cache itself. The rule we settled on: anything we aren't certain is a public, anonymous, full-page GET goes straight to Next without touching the cache. A logged-in editor's request is never cached and never served from cache. A request with a language cookie is skipped, because the cookie changes what an unprefixed path renders, and the cache key is the URL alone. The internal data requests Next's router makes share a URL with the page but return a different body, and the Cache API doesn't honour Vary on arbitrary headers, so those are skipped too.
On the way back, a response is only stored if it's a 200, it's HTML, XML or text, and it sets no cookie other than the harmless locale hint. Any other Set-Cookie header means the response might belong to one visitor, and storing it would hand that visitor's cookie to the next.
When expiry was downtime
The first version of the cache had a simple expiry. A page was cached for a while, then thrown away, and the next request rendered it again.
That was fine until a render failed. On the Workers free plan, a request gets 10 ms of CPU. A render needs 250 ms or more. A page that wasn't in the cache simply couldn't be rendered, the Worker hit its CPU limit, and since we only store successful responses, nothing was stored. The next request tried again and failed again. We watched one tenant site fail six requests out of six over 75 seconds. It couldn't heal itself, because healing needed exactly the render that kept failing.
The fix was stale-while-revalidate, and it changed how we think about caching:
const FRESH_SECONDS = 3600; // serve without any revalidation
const MAX_STALE_SECONDS = 86400; // serve stale while refreshing in the background
const STORE_SECONDS = MAX_STALE_SECONDS + 3600; // keep it in the Cache API at least this long
const REVALIDATE_LOCK_SECONDS = 60; // one refresh at a time per URL
A page is fresh for an hour. Between an hour and a day old, the Worker serves the stored copy immediately and renders a new one in the background. If that background render fails, the old copy stays, and the next visitor gets the old page again rather than an error. A render that can't succeed can no longer empty the cache.
Past a day, the Worker tries a real render first, but if that fails too, it still returns the old copy. An outdated page is better than a 503.
The lock deserves a sentence. Without it, every request that arrived on a stale page would start its own render of the same page, which is the most expensive possible reaction to traffic. So the first request re-stamps the stored copy to look fresh for sixty seconds, and everyone else gets that copy while one render runs. The lock is short on purpose. If the render fails, the page goes back to stale quickly and the next request tries again.
Every response also carries an x-edge-cache header (HIT, STALE and so on) and an x-edge-cache-age header. If a site gets stuck serving old content because something's broken, we can see it from outside with curl rather than finding out from a customer.
Renders, not requests
The comment at the top of our cache module has the sum that set the freshness window, and it's worth repeating.
Workers Paid includes 30 million CPU milliseconds a month. At 250 ms a render, that's only about 120,000 renders. For a site Worker, renders are the scaling limit, not requests. A one-minute freshness window would mean a render per page per minute per data centre that sees traffic. A one-hour window is roughly sixty times fewer renders for the same traffic.
An hour sounds like a long time for a business that just changed its prices. It would be, if that was the only way content refreshed. It isn't.
Purging one tenant in about thirty seconds
When a business edits their site in our dashboard, the dashboard tells the site Worker to purge. The first version did that through a Next.js API route, which then called the Cloudflare API to purge the zone.
That route cost up to about 370 ms of CPU. Part of that was the Next request pipeline, part was module start-up on a cold isolate, and part was several Cloudflare API calls made inline. On the free plan it hit the CPU limit or the dashboard's two-second timeout, and edits didn't reach the sites. A business would change their opening hours and the site would keep showing the old ones.
The purge now runs in the Worker entry, before OpenNext is involved at all. It does a constant-time secret comparison, one host lookup that's normally already cached, and one small KV write. It doesn't delete anything.
What it writes is a generation: the time of the purge, stored at a key for that one site. Every cached copy of every page carries the time its render started. A copy is only served while it was rendered after the latest purge. Bumping the generation makes every cached page for that site out of date, in every data centre, without listing or deleting a single key. Old copies age out on their own.
We keep the old copy around even when it's been purged, for one reason: if the fresh render fails, a pre-edit page is still better than an error. But on the normal path, a purged copy is never returned.
How fresh is "after a purge"? The Worker remembers the generation in memory for 2 seconds. KV caches reads at the edge for at least 30 seconds, which is KV's minimum, though the data centre that wrote the value sees it immediately. So the next request for that site, anywhere, renders fresh within about 32 seconds.
For businesses with their own domain, the dashboard also purges that domain's zone through the Cloudflare cache API, but only when the zone belongs to that one tenant.
Where the HTML lives
Cached pages live in three places. The Cache API is the first, and it's per data centre. Behind it is R2, which holds a snapshot of each rendered page so another data centre can serve it without rendering. We moved those page bodies out of KV in July, because hourly revalidation across data centres was using up the free plan's 1,000 daily KV writes. R2 doesn't have that small daily write ceiling. KV is now used only for the tiny purge markers, which change only when somebody edits their site.
We compared the four storage products in more depth in D1 vs KV vs Durable Objects vs R2.
The bots that were burning our CPU
One more thing, because it surprised us. When we looked at the Worker's logs for the requests that ended in a CPU limit or a cancellation, about 60% of them weren't visitors. They were vulnerability scanners: requests for /.env, /.git/config, config files, PHP files. None of these can exist on a tenant site. But each one went through tenant lookup and a full Next.js 404 render, which costs real CPU. And on the free plan, a render killed by the CPU limit could leave an isolate in a bad state for the real visitors that came next.
So the Worker now rejects obvious probes with a flat 404 before doing anything else. The rule is deliberately narrow, based on file extensions and well-known paths. robots.txt, sitemaps and every path without an extension pass through untouched. It's a few dozen lines, and it took the majority of our CPU failures off the table.
Static files get the same treatment in a different way. The very first thing the Worker does is check whether the request is for a static asset, and if so, hand it to the assets binding. A missing old Next.js chunk gets the cheap asset 404, never a tenant lookup or a render.
The order of the request handler is the summary of this whole post: static assets, then the purge webhook, then scanner probes, then host and content redirects, then the decision about whether a request is cacheable at all, then the tenant lookup, then the cache, and only then Next.js.
What we'd tell you
If you're putting a multi-tenant Next.js app on Workers, move tenant identity out of build-time variables first. It's the biggest job and nothing else works without it. Resolve the tenant from the host, cache the answer briefly, and cache misses for less. Never use a cache keyed by path alone. Build your own cache with the tenant in the key, and be strict about what goes in it. Use stale-while-revalidate, so a failed render can never empty the cache. Make the freshness window long and purges fast, with a generation number rather than deletes. And look at what's actually burning your CPU. For us, it was mostly bots.
If you're on the free plan, our notes on Workers free plan limits explain the 10 ms ceiling that shaped much of this.