Building a multi-tenant sandbox routing layer for an AI website builder
Published November 2025 · by OctopusLab Engineering
Every project on OctopusLab is a real Next.js app running in a real dev server. Not a string of generated HTML stuffed into an iframe, not a snapshot rendered by a headless browser — a live process, hot-reloading, with a working file system, listening on a port somewhere on the internet. That's the only honest way to preview AI-generated code: run it.
The problem is that "somewhere on the internet" is not a place users want to share. Vercel Sandbox SDK hands us a URL that looks like https://sbx-9f2b...c3e1.vercel.run with a 64-character session ID baked in. Functional, but nothing you'd put in a tweet. We promised our users that every project lives at <slug>.octopus-lab.app, and we have to keep that promise even though the underlying compute moves every few minutes.
This post is about the routing layer that makes that work: DNS, edge middleware, an in-memory cache, an R2 fallback for published projects, and one embarrassing regex bug that broke our dashboard for forty minutes on a Tuesday.
What we're routing
There are three kinds of traffic hitting *.octopus-lab.app:
- Live preview — the editor is open, a Vercel Sandbox session is running, every request to
myproject.octopus-lab.appneeds to proxy to the ephemeral sandbox URL. - Published — the user clicked "Publish", we baked the Next.js app into a static export and pushed it to Cloudflare R2. The sandbox is dead. We serve from R2.
- Cold — nobody's touched the project in 24 hours. We need to either spin up a fresh sandbox on demand or serve the last published artifact.
The system has to figure out which mode applies, for which slug, in single-digit milliseconds, without a database round-trip on every request. That last constraint is the interesting one.
DNS: wildcards and a single A record
We own the apex octopus-lab.app and the wildcard *.octopus-lab.app through Cloudflare. The DNS config is intentionally boring:
octopus-lab.app A 76.76.21.21 (Vercel)
www CNAME cname.vercel-dns.com
* CNAME cname.vercel-dns.com
The wildcard CNAME points every subdomain at Vercel's edge. In the Vercel dashboard, the project is configured with *.octopus-lab.app as a wildcard domain. Vercel's certificate authority issues us a wildcard TLS cert via ACME and rotates it without us thinking about it.
There's a temptation, when you see "Cloudflare", to want to use Cloudflare Workers as the routing layer. We don't. Cloudflare is purely the registrar and DNS host here. The CNAME flattening means a request to acme.octopus-lab.app resolves to Vercel's anycast IPs and lands directly on Vercel's edge. No double hop. No Worker. We pay for one edge, not two.
This matters because the next layer — middleware — runs on that same Vercel edge, with single-digit-ms cold latency to the user. Adding Cloudflare in front would add ~20ms on a good day and a second TLS termination on a bad one.
The middleware
apps/web/middleware.ts is the brain. It runs on every request to any subdomain of octopus-lab.app, looks at the Host header, decides what to do, and either rewrites the request internally or proxies it onward.
The skeleton looks like this:
// apps/web/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { resolveSlug } from '@/lib/routing/slug-cache';
const ROOT = 'octopus-lab.app';
export async function middleware(req: NextRequest) {
const host = req.headers.get('host') ?? '';
const url = req.nextUrl;
// Apex and www get the marketing site
if (host === ROOT || host === `www.${ROOT}`) {
return NextResponse.next();
}
const slug = host.endsWith(`.${ROOT}`)
? host.slice(0, -1 - ROOT.length)
: null;
if (!slug) return NextResponse.next();
const target = await resolveSlug(slug);
if (target.kind === 'sandbox') {
return NextResponse.rewrite(
new URL(url.pathname + url.search, target.sandboxUrl),
);
}
if (target.kind === 'published') {
return NextResponse.rewrite(
new URL(url.pathname + url.search, target.r2SignedUrl),
);
}
return new NextResponse('Project not found', { status: 404 });
}
NextResponse.rewrite is the move here, not redirect. The user's browser still thinks it's talking to acme.octopus-lab.app. Vercel's edge proxies the request to the sandbox URL or the R2 URL internally. Cookies, auth headers, and WebSocket upgrades all flow through. For HMR to work — and the editor experience falls apart without HMR — we need WebSocket support, which rewrite handles transparently for sandbox URLs that opt into it.
The slug cache
The naive version of resolveSlug is a Postgres query. Every request to every subdomain does a SELECT against Neon, looks up the active sandbox session URL, returns it. We measured this on a single subdomain under load. With Neon's pooled connection from the edge region closest to us, p50 was about 14ms and p99 spiked to 80ms when the read replica was warm. Not catastrophic, but every preview page makes 30+ asset requests (JS chunks, CSS, fonts, images). Doing 30 round-trips to Postgres per page view is a waste.
The fix is a 30-second in-memory cache, scoped per edge instance:
// apps/web/lib/routing/slug-cache.ts
type Resolution =
| { kind: 'sandbox'; sandboxUrl: string }
| { kind: 'published'; r2SignedUrl: string }
| { kind: 'not_found' };
const TTL_MS = 30_000;
const cache = new Map<string, { value: Resolution; expires: number }>();
export async function resolveSlug(slug: string): Promise<Resolution> {
const now = Date.now();
const hit = cache.get(slug);
if (hit && hit.expires > now) return hit.value;
const value = await fetchResolution(slug);
cache.set(slug, { value, expires: now + TTL_MS });
return value;
}
A few notes on why this is fine, and where it isn't:
- Edge instance scope. Each Vercel edge instance has its own
Map. There's no shared cache, no Redis, no Upstash sitting in front. We don't need one. Each instance warms its own cache against its own request stream. A miss costs one Postgres query. - 30 seconds is a long time when a sandbox dies. If the sandbox URL changes (because the session ID rotated), users will hit a dead URL for up to 30 seconds. We handle this by having the NestJS API on Railway POST to a
/api/v1/internal/cache-invalidateendpoint when it rotates a session, which the edge picks up via Vercel's cache-tag API. In practice the invalidation is best-effort; the worst case is a 30-second stale window and the user sees a refresh. - No negative caching gotchas. We do cache
not_foundresults, also for 30 seconds. When a user first creates a project, the slug propagates to the edge within that window. We accept it.
After the cache, p50 for resolveSlug is 0.4ms (a Map.get) and p99 — when we hit Postgres — is ~16ms. Across a real preview page, the cache hit rate is over 99%, because asset requests far outnumber the initial document request and they all come within the TTL.
The R2 fallback
When a project is published, we run a next build && next export-equivalent inside the sandbox, package the out/ directory, and upload it to R2 under projects/<projectId>/builds/<buildId>/. The Postgres row for the project has published_build_id set.
When the middleware looks up a slug and the sandbox is dead (or never existed for this hostname), fetchResolution returns a published kind with a presigned R2 URL pointing at the build prefix. The actual file resolution — /about → /about/index.html — happens via a small Cloudflare-style request mapper that runs in the same middleware before the rewrite. It's about 40 lines of code and it's the only piece I'd happily rewrite from scratch tomorrow.
Why signed URLs? R2 buckets are private. We never expose the raw bucket. The presign is valid for 10 minutes, scoped to the build prefix, and the middleware regenerates it before expiry. This means a public visitor to acme.octopus-lab.app/about is hitting Vercel's edge, which is rewriting to R2 with a signed URL valid for the next nine minutes. The visitor never sees the signature.
The cost story matters here. R2 has no egress fees, which is the entire reason we picked it over S3 for published artifacts. A popular published site serving 100GB of traffic costs us nothing at the egress layer. We pay R2's storage and operations rates, which round to lunch money at our current scale.
The matcher, and the day we broke the dashboard
Next.js middleware lets you scope the matcher with a regex. Ours initially looked like this:
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
You can probably already see the bug. This matcher runs on /api/v1/projects, on /auth/callback, on every internal dashboard route. Which means when a logged-in user on app.octopus-lab.app hit the dashboard, the middleware parsed app as a slug, looked it up in Postgres, got not_found, and returned a 404. The dashboard went down for everyone whose session was on a clean edge instance.
We caught it because the founder was the first user. Commits afe7b15 (the panic revert) and 458ea89 (the real fix) landed within an hour of each other. The real fix is an anchored negative lookahead on the matcher itself:
export const config = {
matcher: [
'/((?!api/|auth/|_next|_vercel|.*\\..*).*)',
],
};
The trick worth calling out: the .*\\..* clause excludes anything with a dot in the path. That covers /favicon.ico, /robots.txt, /site.webmanifest, and every static file. We used to enumerate them. We don't anymore. If it has an extension, it's a file, and middleware shouldn't touch it.
The api/, auth/, _next, and _vercel exclusions are the real load-bearing pieces. api/ is our NestJS proxy and internal Next.js route handlers. auth/ is Supabase's callback flow, which uses query params we must not eat. _next is the framework's own asset pipeline. _vercel is platform internals — health checks, deployment routing, the things that break in subtle ways if you intercept them.
We also added a runtime guardrail. If the parsed slug is in a hard-coded reserved set ('app', 'api', 'auth', 'admin', 'docs', 'status'), the middleware bails out and forwards normally:
const RESERVED = new Set(['app', 'api', 'auth', 'admin', 'docs', 'status', 'www']);
if (RESERVED.has(slug)) return NextResponse.next();
Belt and suspenders. The matcher should already exclude these paths, but if someone in the future loosens the matcher, this list catches it.
A note on what we didn't build
We considered putting Cloudflare Workers in front of Vercel and doing the slug routing there. We considered standing up an HAProxy on a Hetzner box. We considered a custom Go reverse proxy on Railway alongside our NestJS API.
We picked Vercel middleware because we already pay for the edge, it already has TLS, the latency is right, and we don't want to operate another service. The day we have a paid tier with five-figure project counts and we're feeling cache eviction pressure on a hot edge instance, we'll reconsider. Until then, a Map and a 30-second TTL is the right answer.
What's next
We have three things on the roadmap for this layer:
- Per-region sandbox pinning. Right now a sandbox can spin up in any region Vercel decides on. For a user in Singapore previewing their project, a sandbox in iad1 adds 200ms of round-trip latency to every HMR event. We want to pin sandboxes to the region nearest the editor session and, on the published side, replicate R2 artifacts closer to traffic.
- Longer-lived sandboxes for paid tiers. Free-tier sandboxes get reaped after 15 minutes of inactivity. For paid users we want hour-long sessions, so coming back to a project after lunch doesn't trigger a cold start. This is mostly a billing-policy change layered on top of the same routing layer.
- An admin override for breaking out of slug routing. When we're debugging a stuck sandbox, we currently SSH into the edge logs and trace by request ID. A signed admin cookie that forces middleware to skip the cache and dump the resolution to a header would save us an hour every time something weird happens at 2am.
The router is one of those pieces of infrastructure that does almost nothing visible when it works. That's the goal. A user types acme.octopus-lab.app and sees their site. The fact that there's a wildcard CNAME, a Vercel edge, a Map cache, a Postgres lookup, a sandbox session, and an R2 fallback artifact behind that hostname is exactly the kind of complexity we want to hide.
Want updates? Watch github.com/jarwiscode/OctopusLab or write to hello@octopus-lab.app.