Running a Next.js App on Cloudflare Workers with Vinext
I run baskiusta.com, an online 3D printing service. You upload an STL file, you get a price in a second, you pay, we print it and ship it. The whole thing runs on Cloudflare Workers.
That last part is the interesting one. Next.js on Workers is not the normal path, and most of what I learned is not written down anywhere. So here it is.
Why Workers at all
The app needs four things: a database, file storage, a cache, and a place to run server code. On Cloudflare that is D1, R2, KV and Workers. They all sit behind one wrangler deploy and one bill. There is no connection pool to size, no region to pick, no container to keep warm.
For a solo project that matters more than raw performance. Every piece of infrastructure I do not run is a piece I do not debug at 2am.
The cost is that Workers are not Node. No filesystem, no long-running process, and a hard memory ceiling per request. Most Next.js hosting assumes otherwise.
The setup
I use Vinext, a Vite plugin that reimplements the Next.js API surface. You keep writing app/ router code, but Vite builds it and a Worker serves it. I am on 1.0.0-beta.4 with Vite 8.
The whole wiring is one config file:
import vinext from "vinext"
import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter"
import { cloudflare } from "@cloudflare/vite-plugin"
export default defineConfig({
plugins: [
vinext({
cache: {
data: kvDataAdapter({ binding: "CACHE" }),
},
}),
cloudflare({
viteEnvironment: {
name: "rsc",
childEnvironments: ["ssr"],
},
}),
],
})
That cache.data line is what backs "use cache" and revalidateTag. On Vercel this is invisible infrastructure. Here you point it at your own KV namespace and you can see exactly what is in it.
The Worker entry is a normal Worker:
import handler from "vinext/server/app-router-entry"
import { env } from "cloudflare:workers"
Three things that are not like Next.js
Bindings come from cloudflare:workers. You import env directly. There is no getPlatformProxy, and process.env is not where your D1 database lives. This trips people up because the two look interchangeable in dev and are not.
Middleware is proxy.ts, not middleware.ts. Same idea, different file. Mine checks the real session on protected routes:
export default async function proxy(request: NextRequest) {
// ...
const session = await auth.api.getSession({ headers: request.headers })
}
That is a real database lookup, not a cookie sniff. On Workers it is cheap enough to do on every protected request, which is a nice side effect of being close to the data.
Secrets need to be in two places in dev. The Cloudflare Vite plugin reads Worker bindings from .dev.vars, not from .env.local. If your server code reads a secret at runtime, .env.local alone will not do it. I lost an evening to this one.
Gotcha 1: the dependency optimizer can eat your dev server
On a cold bun run dev I started getting this:
auditPlugin is not a function
The plugin was fine. What happened is that Better Auth resolves its internals through deep subpath exports, and Vite can only discover those one hop at a time. Each discovery triggers an "optimized dependencies changed, reloading" cycle. I was getting about fifteen of them in a row, and one reload landed in the middle of evaluating my auth module. The module's exports came back empty and the dev server died.
The error message points at your code. The cause is the optimizer.
The fix is to declare the whole dependency closure up front so the optimizer does a single pass:
environments: {
rsc: {
optimizeDeps: {
include: BETTER_AUTH_DEPS, // ~45 entries
},
},
},
You can generate the list from Vite's own metadata:
node -e "console.log(Object.keys(require('./node_modules/.vite/deps_rsc/_metadata.json').optimized))"
If you see a cold-boot crash that goes away on the second start, suspect this before you suspect your code.
Gotcha 2: out-of-memory crashes are invisible to your error tracker
This is the one I would most like to have known earlier.
Parsing a large STL file in a Worker can blow the memory limit. Cloudflare kills the isolate and returns Error 1102. What I saw in the browser was a JSON parse error, because the client got an HTML error page where it expected JSON. I spent real time looking for a serialization bug that did not exist.
Sentry showed nothing. That is not a misconfiguration. A resource-limit kill terminates the V8 isolate mid-execution. No catch block runs, no unhandledrejection fires, and Sentry.flush() never gets the chance to send anything. The in-worker SDK physically cannot see its own death.
The way out is a tail worker. A tail worker runs in its own invocation, after the producer's trace is sealed, so it can read the final outcome:
const FORWARD_OUTCOMES = new Set([
"exceededMemory",
"exceededCpu",
"exceededResources",
])
export default {
async tail(events) {
await Promise.all(
events
.filter((e) => FORWARD_OUTCOMES.has(e.outcome))
.map((e) => report(e).catch(() => {}))
)
},
}
Forward only the resource kills. Thrown exceptions are already captured by the producer, and forwarding those too gives you every error twice.
So my rule now is: OOM and CPU kills live in Sentry through the tail worker, everything else lives in logs and traces. If a crash leaves no trace anywhere, it is almost certainly a 1102.
For the actual fix I moved heavy geometry work off the Worker. Files above a size threshold go to a small VPS that does the parsing and hands back the numbers. Workers are great at glue and bad at 200MB meshes.
Gotcha 3: bundle size is a runtime concern, not just a page speed one
Workers have a startup time budget and a bundle size limit. A top-level import * as THREE from "three" in a page component puts roughly 500KB into a chunk that loads on every page, including the homepage that has no 3D on it.
The rule I follow now is that heavy libraries are imported inside the function that needs them:
// Loads only when the user actually drops a file
const { STLLoader } = await import("three/addons/loaders/STLLoader.js")
const { analyzeSTL } = await import("@/lib/stl-analyzer")
Type-only imports at the top level are free, so you keep your types:
type BufferGeometry = import("three").BufferGeometry
One related trap. My 3D viewer library ships its own copy of Three.js, so I had two instances in the bundle. That is a console warning and about 280KB of waste. Vite fixes it in one line:
resolve: { dedupe: ["three"] }
Would I do it again
Yes, with one caveat.
The good part is real. One deploy command, one dashboard, no idle cost, and the database sits next to the code. Response times are good without me tuning anything. For a small commercial site this is a genuinely nice place to be.
The caveat is that you are early. Vinext is at 1.0 beta. When something breaks, you are not going to find a Stack Overflow answer, and the error message will usually point somewhere other than the cause. Two of the three problems above cost me a day each, and neither had anything to do with the code I was writing.
If that trade sounds fine to you, the platform is worth it. If you need to ship on a deadline with a team that expects Google to have the answer, use the boring option.
I would also say: put the tail worker in on day one. Not after you spend an afternoon chasing a JSON error that was never a JSON error.