Spike: Void in place of the Vite plugin, cloudflare.config.ts and flarestack-cli
Date: 2026-10-16. Void 1.1.11 (published 2026-10-15), Node 24.22.0.
Code: starters/minimal-void, a copy of starters/minimal with
@cloudflare/vite-plugin + cloudflare.config.ts + flarestack-cli replaced by
void (exact pin). No account was used; nothing was deployed.
Recommendation
Section titled “Recommendation”Go with Void, with Flarestack as glue on top. The repo is experimental and should be minimal glue over existing tools. Void is the tool: it owns dev, build, the database workflow, env and secrets, deploys, and now auth. What Flarestack adds on top stays: resources, organizations and roles, the admin, realtime, jobs, mail. How that becomes small plugins is in “Plan” below.
What the starter shows (starters/minimal-void):
- One middleware hands
/api/*(but/api/auth) tocreateServer(). - Auth is Void’s root
auth.ts: Better Auth with email + password, PBKDF2, organizations and admin. Flarestack reads the user withgetSession()fromvoid/authand builds no Better Auth of its own. API keys, MCP, passkeys, two-factor and OAuth are left out for now. void db generatewrites every migration, auth tables included, with Flarestack’s column names.- Crons, queues and the Room run through Void’s
crons/,queues/anddurable-objects/with our binding names. pnpm testbuilds the Worker and drives it in workerd through Wrangler’s test harness: sign-up, sign-in, organizations, tasks, tenancy.
Still open before the other starters move:
- A real deploy.
void deployhas no dry run. Wrangler’s dry run on Void’s build resolves every binding, but nothing has run on an account. - Preview stages and
APP_URL. Void has no named environments; each stage would be its own project config. - Queues. Void passes consumers no ExecutionContext and configures no dead-letter queue; the workarounds are in question 4.
- API keys and MCP, when they come back, go into
auth.tsas Better Auth plugins./api/keysand/mcpthen need Void’s auth instance, which Void keeps under an undocumented context key.
8. What Void forces on a Cloudflare-only app that we don’t use
Section titled “8. What Void forces on a Cloudflare-only app that we don’t use”Measured on starters/minimal-void. In the Worker, bytes per package come from
the source maps of an unminified build, compared with starters/minimal
today. In node_modules, sizes are du of the pnpm store entries.
In the built Worker: about 218 KiB of unminified source, all from Void itself. None of pg, mysql2, better-sqlite3, the Node/Bun/Deno targets or the framework adapters reaches the bundle. Every other package (kysely included, which comes from Better Auth) is the same size in both builds. Void adds:
| Source | KiB | What it is |
|---|---|---|
void/dist/runtime/routing.mjs |
38.2 | request pipeline, asset fallback, /__void/* endpoints |
generated virtual:void-routes |
41.5 | the Hono app Void writes from routes/middleware/crons/queues |
@void/deploy-core |
32.7 | migration-plan verification (SQL tokenizer, digests) for deploys |
@void/edge |
31.3 | redirect/header/rewrite rules and ISR runtime: unused here |
void/dist/migration-handler-* |
18.1 | /__void/migrate/*, for the Void Platform; 401 without __VOID_PROXY_TOKEN |
void/dist/runtime/env-public.mjs and env helpers |
19.5 | env.ts validation at startup |
void/dist/runtime/remote/binding-handler.mjs |
8.6 | proxy for remote bindings in void dev |
void/dist/runtime/durable.mjs |
7.0 | defineDurableObject binding glue |
| the rest (handlers, assets, R2 conditions, entry) | 21 |
Minified, that’s the +79 KiB / +21 KiB gzip from question 6. Most of it is
there for the Void Platform, ISR, edge rules or remote dev. Of what an API-only
flarestack app uses, only routing, env validation and the Durable Object glue
are left, and flarestack’s server.worker() already does that job.
In node_modules, for things we don’t use (~44 MB):
| Part | Size |
|---|---|
better-sqlite3 + node-addon-api (the build script is denied; local D1 runs on miniflare anyway) |
26.6 MB |
@void/platform, @void/deploy-core, @void/deploy-cloudflare, @void/isr, @void/edge |
6.0 MB |
@cloudflare/codemods (Wrangler-config migration) |
4.2 MB |
arktype, valibot, drizzle-arktype, drizzle-valibot, drizzle-zod |
3.9 MB |
mysql2 and its 7 helpers |
1.6 MB |
pg and its 12 helpers |
0.8 MB |
@napi-rs/keyring (CLI login storage) |
0.6 MB |
@cloudflare/sandbox |
0.1 MB |
Inside void/dist (4.3 MB): Void Platform/account/project commands 0.9 MB, pages/islands/ISR 0.25 MB, pg/mysql 0.12 MB, framework adapters and sandbox 0.06 MB |
~1.3 MB |
The Node/Bun/Deno targets cost almost nothing on disk (the code is a few
modules in void/dist); @hono/node-server isn’t installed.
The big cost is version skew, not the multi-target parts: 313 MB. Void
brings its own @cloudflare/vite-plugin@1.64.0, cf@1.0.0-beta.17,
@cloudflare/config@0.26.0, miniflare@5.20261012.0-beta, workerd@1.20261012.1
and needs wrangler@4.151.0. We pin @cloudflare/vite-plugin@2.0.0-beta.sha-91c870c02,
cf@1.0.0-beta.14 and wrangler@4.149.0. Lining up our pins with Void’s would
remove most of that. But Void sits on the 1.x Vite plugin, so we would have
to step back from the 2.0 beta, and every Void release moves those versions
for us.
Would plain @cloudflare/vite-plugin + cf with a slimmer flarestack-cli do
the same with less? For the Worker, yes. But then Flarestack keeps owning
deploys, the local database, migrations and auth, which is the opposite of
minimal glue.
- Worker.
server.worker()already returnsfetch,queueandscheduled, with the Room export, for the plain plugin to serve. That’s the same result as Void’s middleware,crons/,queues/anddurable-objects/files. It needs no ExecutionContext stand-in, keeps the dead-letter queue as configured, keeps the class nameRoom, and is 21 KiB gzip smaller. - Install. None of the 82 extra lockfile packages.
- Tests.
flarestack/testingand@cloudflare/vitest-pluginwork today; with Void, the vitest plugin can’t load next tovoidPlugin(). - What Void does that a slim CLI would have to keep doing:
void db migrateagainst the local databasevite devserves. Today that’s flarestack-cli’s local-D1-key hashing (§17), the most fragile part.- Staged deploys with a readiness check and rollback, and
void.lock.jsonfor resource IDs.cf deploydoesn’t do these. They’re the one real gain. - Neither one has preview stages. Void says to use one project config per
target; flarestack-cli has
--stage.
- What “slimmer” means. flarestack-cli is 1,879 lines with 3 dependencies.
About 840 are infrastructure (
deploy.ts369,db.ts161,dev.ts48,remote.ts47,config.ts50,util.ts166). The rest are the generators (create,make, templates), which Void doesn’t replace either. The infrastructure part can shrink tocf deploy+cf d1 migrations apply, plus the parts Cloudflare doesn’t cover: stages,APP_URL, the secrets file and finding the local D1.
Plan: Flarestack as small plugins on Void
Section titled “Plan: Flarestack as small plugins on Void”The app is a Void app (starters/default). Flarestack is a set of small,
optional plugins for what Void lacks, plus a CLI that adds them. There is no
createServer() and no runtime registry: Void’s routing, middleware, auth and
database do that job.
What a plugin is
Section titled “What a plugin is”Void has no API for third-party plugins (voidPlugin() only takes
persistTo and auxiliaryWorkers). So a plugin touches the app only through
the seams Void already has, each a line or a file the app owns:
| Seam | How the app uses it |
|---|---|
| Code | import { requireMember } from "flarestack/access" |
| Tables | export * from "flarestack/audit/schema" in db/schema.ts; void db generate migrates them |
| Auth | a Better Auth plugin or option in auth.ts |
| Routes | a one-line file, routes/api/admin/[...path].ts: export { default } from "flarestack/admin/route". Void mounts a route file with only a default export for every method |
| Middleware | a one-line file in middleware/ |
| Bindings | declared by the app in void.config.ts; the plugin reads them from c.env |
flarestack add <plugin> writes those lines and any template files, the way
shadcn add does. Generated code belongs to the app; library code stays in
the package and is updated with it.
Plugins assume Void’s default auth tables (Better Auth’s names, as
starters/default has them). fieldsOf and ServerConfig.auth.session from
PR #11 only exist to bridge packages/core, and go with it.
The plugins
Section titled “The plugins”| Plugin | Kind | Seams | From core | Order |
|---|---|---|---|---|
access |
library | code, auth | auth/access.ts, auth/password.ts, lib/tenant.ts in the default starter |
1 |
resource |
generator | writes table, schemas, two route files | resource/ |
2 |
audit |
library | code, tables | audit.ts |
3 |
openapi |
library | code, a route file | http/openapi.ts |
4 |
admin |
library + route | route, depends on access |
admin/ |
later |
flags |
library | code, KV binding | flags.ts |
later |
i18n |
library | code | i18n/ |
later |
search |
library | code, a hand-written FTS migration | search/ |
later |
csv |
library | code | csv.ts |
later |
api-keys |
auth plugin + library | auth, code (keys narrow a member’s permissions) | auth/keys.ts, auth/policy.ts |
when needed |
mcp |
library + route | route, tools from route validators, needs api-keys |
mcp/ |
when needed |
Not plugins, because Void or Better Auth already does them: server.ts,
http/ (but OpenAPI), db/, auth/server.ts and auth/middleware.ts,
jobs/ (crons/, queues/), mail/ (void/email; auth mails go in
auth.ts), storage/, ai/, cache/, realtime/ (Void’s *.ws.ts
routes or durable-objects/), observability/, testing/ (Wrangler’s test
harness on the build), and security headers (routing.headers in
void.config.ts).
Two gaps from question 4 may need a few lines each, as part of jobs if it
comes back: queue consumers get no ExecutionContext, and Void configures no
dead-letter queue.
Worked example: access
Section titled “Worked example: access”Everything else uses it, it’s about 100 lines, and the default starter already has it as
app code (lib/access.ts, lib/tenant.ts, lib/password.ts).
The package, flarestack/access:
// One map of resources and actions; owner and admin get all, member all but// delete, viewer list and read. Built on Better Auth's access control.export const defineAccess = <S extends Record<string, readonly string[]>>(statement: S) => ({ ac, // createAccessControl({ ...defaultStatements, ...statement }) roles, // owner, admin, member, viewer can, // (role, permission) => boolean; roles may be comma-separated requireMember, // (c, permission) => { userId, organizationId, role }; 401/403})export { hashPassword, verifyPassword } // PBKDF2-SHA256, 100,000 iterationsWhat flarestack add access writes into the app:
// lib/access.ts (owned by the app: add resources here)import { defineAccess } from "flarestack/access"export const access = defineAccess({ task: ["list", "read", "create", "update", "delete"] })// auth.ts (two options added)import { hashPassword, verifyPassword } from "flarestack/access"import { access } from "./lib/access.ts"// emailAndPassword: { enabled: true, password: { hash: hashPassword, verify: verifyPassword } }// plugins: [organization({ ac: access.ac, roles: access.roles, creatorRole: "owner" })]In a route:
export const GET = defineHandler(async (c) => { const m = await access.requireMember(c, { task: ["list"] }) return db.select().from(tasks).where(eq(tasks.organizationId, m.organizationId))})Done when the default starter’s lib/access.ts, lib/tenant.ts and lib/password.ts
are replaced by the package and its 9 tests still pass.
Open questions
Section titled “Open questions”- Packaging. One
flarestackpackage with a subpath per plugin (flarestack/access,flarestack/audit), or one package each (@flarestack/access). Subpaths mean one version and one install, and can be split later. Start with subpaths. resourceas a generator only. Each resource is about 130 lines of plain code (question 9), against about 55 through core. If that grows tiresome, a small helper for the list query (cursor, search, filters) can move into a library without hiding the routes.- When
packages/coregoes. The other starters and examples still use it. They move one at a time; core is deleted after the last one. - The Angular packages (
packages/angular,packages/ui) aren’t part of this. Void serves the built client.
Answers
Section titled “Answers”1. One catch-all hands requests to createServer(), hc types intact: yes
Section titled “1. One catch-all hands requests to createServer(), hc types intact: yes”A middleware does it (middleware/00.flarestack.ts). Void’s middleware runs
before its routes, and only flarestack’s paths are handed over:
const owned = /^\/api(?:\/(?!auth(?:\/|$))|$)/ // /api/auth is Void'sexport default defineMiddleware(async (c, next) => { if (!owned.test(c.req.path)) return next() return worker.fetch(c.req.raw, c.env as never, c.executionCtx)})Evidence, against vite dev on the Void starter, before auth moved to Void
(the starter then handed /mcp and /.well-known/* over too):
- Sign-up → organization →
POST /api/keys→POST /api/tasks(201) →GET /api/taskslists it →POST /mcptools/listreturnslist_tasks. /api/openapi.json200;/mcpwithout credentials 401 (flarestack’s JSON-RPC error);/__void/ready200 (Void’s own paths still work).- With
auth: { oauth: true }(temporarily),/.well-known/oauth-protected-resourceand/.well-known/oauth-protected-resource/mcpreturn flarestack’s metadata. hctypes:test/types.tsbuildshc<AppType>and callsclient.api.tasks.$get; a@ts-expect-erroron a missing route holds.pnpm checkpasses.AppTypedoesn’t change, because the app doesn’t.
A catch-all route also works in principle: Void’s codegen mounts a route file
with only a default export as app.all(pattern). Method exports are limited
to GET/POST/PUT/PATCH/DELETE/OPTIONS/HEAD (no ALL). /mcp and /.well-known
would need their own files, so the middleware is simpler.
Notes:
- Void adds the
nodejs_alscompatibility flag on its own. - Void refuses any dotenv file besides
.env,.dev.vars.exampleincluded. Secrets move toenv.ts(defineEnv) andvoid secret put.
2. A root auth.ts with defineAuth() takes Flarestack’s Better Auth: yes, with a small change in packages/core
Section titled “2. A root auth.ts with defineAuth() takes Flarestack’s Better Auth: yes, with a small change in packages/core”First try: a hand copy of all of Flarestack’s options (organization, passkey,
api-key, bearer, admin, mcp, cimd, PBKDF2, the Drizzle adapter on our schema)
in auth.ts. It built and sign-up worked, but:
- Void’s auth middleware reads the session on every request, before ours, so there were two Better Auth instances and two session reads per request.
- Void generates the auth tables from the options with Better Auth’s
camelCase names. Ours are snake_case, so
void db generatestopped on a column-rename prompt andvite devloggedCannot add required auth fields "createdAt", "updatedAt" to table "user".
So Flarestack’s auth was dropped instead. What the starter does now:
auth.tsis Void’s....defaults(Void’s base URL, secret, D1 database,/api/auth), plus PBKDF2 fromflarestack/auth/password, the cookie cache, and theorganization(our roles) andadminplugins.- Same column names as Flarestack. Each table’s
fields(and each plugin’sschema) come fromfieldsOf(table)(flarestack/auth/fields), which maps Flarestack’s Drizzle fields to its column names. Void’s generated migration then hasemail_verified,organization_id,active_organization_idand so on, so Flarestack’s own queries onuserandmemberread the same tables. - One session read.
createServer({ auth: { session: async () => getSession() } }). Withauth.sessionset, Flarestack builds no Better Auth. It also serves no/api/auth,/.well-knownor/api/keys(404), and OAuth is off.server.worker({ mcp: false })leaves/mcpout.
Changes in packages/core (the default path is unchanged; core’s 158 tests
and the minimal and angular starters’ tests pass):
ServerConfig.auth.session: the signed-in user from an external auth.sessionMiddlewaretakessessionas an alternative toauth.WorkerConfig.mcpmay befalse.flarestack/auth/fields:fieldsOf(table).SessionResultis exported.
Notes:
- Void calls the
defineAuthfunction per request with{ defaults, request, env }, and also in Node at build andvoid db generatetime without bindings. The starter’s function reads neither, so that’s fine. - Void trusts only the request’s own origin by default, so a request whose
Originisn’t the dev server’s gets 403 on sign-up. - Left out for now: API keys, MCP, OAuth, passkeys, two-factor, magic links, teams, first-user-is-admin, auth mails, rate limits stored in D1 (Better Auth’s in-memory limits apply).
3. Migrations move to db/migrations with history; void db generate / migrate work: yes
Section titled “3. Migrations move to db/migrations with history; void db generate / migrate work: yes”Tested with the old migrations before auth moved to Void. Since then the
starter’s migrations are regenerated from scratch (one
db/migrations/<timestamp>_*.sql), because the auth tables are Void’s now.
- Moved
migrations/as is (SQL,meta/_journal.json, snapshots) todb/migrations/, plusdb/schema.tsre-exportingsrc/server/schema.ts. void db status→0000_init.sql local: applied,0001_oauth.sql local: applied. (vite devhad applied both on first start.)void db generate→No schema changes, nothing to migrate.- Adding a column →
void db generatewrotedb/migrations/20261016152654_spotty_sprite.sql(ALTER TABLE tasks ADD priority text) as journal entry 2;void db migrate→Applied 1 migration. Reverted after. - New migrations get timestamp names; the old numeric ones stay (Void can’t rename them for direct Cloudflare deploys, and doesn’t need to).
void db generatehas no--name.- Remote migrations need
migrations_diron the D1 binding (set invoid.config.ts). better-sqlite3is a Void dependency, but its build script is denied (allowBuilds: better-sqlite3: false) and every localvoid dbcommand still worked.
4. Crons, queues, the Room Durable Object, our binding names: yes, with two gaps in queues
Section titled “4. Crons, queues, the Room Durable Object, our binding names: yes, with two gaps in queues”Bindings (void.config.ts cloudflare block, passed through as Wrangler
config): DB, KV, FILES, RATE_LIMITER, ANALYTICS, JOBS keep their
names. inference.bindings stops Void from adding STORAGE/AI.
- Crons.
crons/schedules.tsexportscron = ["0 8 * * *"]and forwards to flarestack’sscheduled. The build hastriggers.crons: ["0 8 * * *"].POST /__void/scheduled→{"ok":true}, and flarestack loggedno task registered for cron(its handler ran). Every expression must be listed in acrons/file, apart from the app’sschedules. - Queues.
queues/jobs.tsforwards to flarestack’squeue. AJOBS.send()from a dev route was delivered: flarestack loggedunknown job; dropping message, thenQUEUE jobs 1/1. Gaps:- Void calls consumers with
(batch, env)and no ExecutionContext. Flarestack’s jobs usewaitUntil, soqueues/_consume.tspasses a stand-in that waits for them. - Void-managed queues get no dead-letter queue, which flarestack’s
failed_jobsneeds. Declaring the consumer invoid.config.ts(dead_letter_queue: "jobs-dlq") adds a second consumer entry forjobsnext to Void’s own ([{"queue":"jobs"},{"queue":"jobs","max_retries":3,"dead_letter_queue":"jobs-dlq"}]). Wrangler’s dry run accepts it; a real deploy may not (one consumer per queue). Unverified. - Void also adds its own
QUEUE_JOBS/QUEUE_JOBS_DLQproducers.
- Void calls consumers with
- Room.
durable-objects/realtime.tsisdefineDurableObject({ name: "realtime", class: Room })→ bindingREALTIME. A WebSocket to/api/realtime/user:<id>with a session cookie got Room’s{"type":"welcome",...}(with Flarestack’s auth; not retried since auth moved to Void). Void exports the class asRealtimeDurableObjectwith migration tagvoid-durable-realtime-v1, notRoom, so a Worker already deployed withRoomneeds a rename migration. Void’s docs also say Cloudflare can’t stage an existing Durable Object Worker, so the first deploy may need--atomic.
5. @cloudflare/vitest-plugin runs the tests in workerd with voidPlugin(): no; Wrangler’s test harness on the built Worker does
Section titled “5. @cloudflare/vitest-plugin runs the tests in workerd with voidPlugin(): no; Wrangler’s test harness on the built Worker does”- With
plugins: [voidPlugin(), cloudflareTest(...)]:Startup Error: ReferenceError: exports is not defined at runInRunnerObject (workers/runner-worker/index.js:107:3). - Without
voidPlugin(), pointed at the Wrangler config Void writes, the plugin ran the old API test against Flarestack’s worker directly (Tests 2 passed). That skips Void’s routing and, now, its auth. - What the starter does instead:
pnpm testrunsvite build, thentest/api.test.tsstartsdist/ssr/wrangler.jsonwith Wrangler’screateTestHarness(the same harnessflarestack/testinguses) and applies the migrations. So the test goes through the real built Worker: Void’s routing, Void’s auth, then Flarestack.Tests 5 passed: sign-up, PBKDF2 hash inaccount.passwordand sign-in, organization + task, a user outside the organization gets 403, anonymous gets 401,/api/keysand/mcp404, OpenAPI.@cloudflare/vitest-pluginis gone from the starter. flarestack/testing’sstartAppstill readscloudflare.config.ts, so the starter doesn’t use it.
6. vite build works; void deploy --dry-run resolves every binding: build yes; Void has no dry run
Section titled “6. vite build works; void deploy --dry-run resolves every binding: build yes; Void has no dry run”vite build→dist/ssr/index.jsplus chunks anddist/ssr/wrangler.json, in under a second.void deploy --help: no--dry-run, no plan option. Without credentials it would start a browser login, so it wasn’t run.- Nearest equivalent:
wrangler deploy --dry-run --config dist/ssr/wrangler.json(Wrangler 4.151.0) listsREALTIME (RealtimeDurableObject),KV,QUEUE_JOBS_DLQ,QUEUE_JOBS,JOBS,DB,FILES,ANALYTICS,RATE_LIMITER (300 requests/60s),APP_URL,MAIL_TRANSPORT, then--dry-run: exiting now. - Size:
Total Upload: 2026.34 KiB / gzip: 514.96 KiB, against1947.51 KiB / gzip: 494.40 KiBforstarters/minimaltoday, measured the same way: +79 KiB, +21 KiB gzip (~4%). The extra is Void’s runtime (routing, env validation, a Kysely SQLite introspector for its schema check).
Lost against flarestack-cli (decisions §17), from Void’s docs:
- Preview stages. “Named Cloudflare environments aren’t supported. Use a
separate Void project config for each target.”
flarestack deploy --stage pr-7has no equivalent. APP_URLfrom the workers.dev URL. It’s a plainworker.varsvalue now.flarestack check(startup profiling) and secrets from the shell environment (nowvoid secret put, validated againstenv.ts).- Gained: staged deploys with a readiness check and rollback,
void.lock.jsonfor resource IDs,void db migrate --remote,void project rollback.
7. Install size and dependency delta: +82 lockfile packages, +481 MB in this monorepo
Section titled “7. Install size and dependency delta: +82 lockfile packages, +481 MB in this monorepo”pnpm-lock.yamlpackages: 821 onmain, 903 with the Void starter (+82). 18 of those are per-platform optionals (workerd ×5,@napi-rs/keyring×13).node_modules/.pnpm: 539 → 633 entries, 1140 MB → 1621 MB.- Most of that is a second Cloudflare toolchain, because Void’s ranges
don’t meet our pins:
@cloudflare/vite-plugin@1.64.0(we use2.0.0-beta.sha-91c870c02),cf@1.0.0-beta.17(we use beta.14),@cloudflare/config@0.26.0,miniflare@5.20261012.0-beta,workerd@1.20261012.1(~255 MB with its binary), andwrangler@4.151.0. The last one we added to the starter: Void’s Vite plugin refuses to load with ourwrangler@4.149.0(does not satisfy the peer dependency ... ^4.151.0). - Void’s own extras:
pg(and 12 helpers),mysql2(and 7),better-sqlite3,@cloudflare/sandbox,@cloudflare/codemods,arktype,valibot,drizzle-zod,drizzle-valibot,drizzle-arktype,@napi-rs/keyring,@void/*(5). - Per starter (packages reachable from each, approximate):
starters/minimal628 MB,starters/minimal-void708 MB. If every app moved to Void, the first toolchain (vite-plugin 2.0 beta, cf beta.14, flarestack-cli) would go, and the delta would shrink to Void’s extras. - Install passed the repo’s 1-day
minimumReleaseAgewithout exclusions. On the first try pnpm reported no matching@cloudflare/workers-types@^5.20261014.1from stale metadata; a plainpnpm installresolved5.20261015.1. engines.node >=24.21.0isn’t enforced: build andvoid db statusalso ran on 24.18. CI usesnode-version: 24.
9. Without the flarestack package: what the app writes itself (starters/default)
Section titled “9. Without the flarestack package: what the app writes itself (starters/default)”starters/default has no flarestack dependency. Void’s routes
(routes/api/tasks/index.ts, [id].ts) with defineHandler.withValidator,
void/db, and requireAuth/getSession from void/auth. The rest is app
code under lib/, the way create-t3-app and shadcn hand you files to own:
lib/access.ts: owner, admin, member and viewer as Better Auth access control roles.auth.tsgives them to the organization plugin.lib/tenant.ts:requireMember(c, { task: ["update"] })reads the session’sactiveOrganizationId, looks up the member row and checks the role. 401, or 403 without an organization or the permission.- One tasks resource: list (newest first, keyset cursor,
status,q), get, create, update, delete. Every query filters on the organization; a task of another organization is a 404. Members delete only their own. routes/api/openapi.json.ts: OpenAPI written out by hand, with bodies and parameters from the same zod schemas throughz.toJSONSchema.
pnpm test passes 9 tests on the built Worker in Wrangler’s harness:
CRUD, paging and search, validation, tenancy, roles through invitations,
PBKDF2, anonymous 401s, OpenAPI.
Line counts (all lines / without blanks and comments):
starters/default |
lines |
|---|---|
lib/access.ts |
23 / 14 |
lib/tenant.ts |
45 / 30 |
lib/password.ts |
30 / 19 |
lib/schemas.ts |
24 / 16 |
db/schema.ts |
19 / 16 |
routes/api/tasks/index.ts |
37 / 32 |
routes/api/tasks/[id].ts |
42 / 35 |
routes/api/openapi.json.ts |
32 / 28 |
| Total | 252 / 190 |
The same features through packages/core take 145 lines of app code in
starters/minimal-void (resource, table, schemas, server, middleware,
auth.ts), on top of this much core:
packages/core/src |
lines |
|---|---|
resource/ (index.ts, routes.ts) |
421 / 328 |
tenant/index.ts |
79 / 48 |
auth/policy.ts |
90 / 51 |
auth/access.ts |
39 / 24 |
auth/password.ts |
33 / 20 |
http/ (app, errors, openapi, validate, error-page, rate-limit) |
475 / 356 |
| Total | 1,137 / 827 |
Not a like-for-like trade: core also does the audit log, soft delete, FTS,
filter[x][gte] ranges, hooks, API key narrowing, MCP tools and admin
screens from the same definition. tenant/index.ts is mostly
TenantObject, a Durable Object per organization that the tasks don’t use.
Each further resource in the default starter costs about 130 lines (table, schemas, two
route files, its OpenAPI entry), against about 55 in minimal-void.
Keep, template, drop:
| Core piece | Verdict | Why |
|---|---|---|
auth/password.ts |
Keep, small library | Security code, the same in every app. 30 lines. Better: upstream to Void as its default on Cloudflare |
auth/access.ts (defineAccess) |
Keep, small library | The four roles from a resource map; easy to get subtly wrong by hand |
requireMember (lib/tenant.ts) |
Keep, small library | Same in every app; depends on Void’s auth table names, so it should move with Void |
Cursor encode/decode, LIKE escaping (db/index.ts) |
Keep, small library | A few lines each, easy to get wrong |
audit.ts |
Keep, if wanted | Not in the default starter; one insert per write |
tenant/index.ts (TenantObject) |
Keep, if wanted | Not needed for row-level tenancy; useful for per-tenant sequences and locks |
resource/ (defineResource, resourceRoutes) |
Template | make:resource writes the table, schemas, both route files and the OpenAPI entry. Plain code is easier to read and change than options |
auth/policy.ts (Actor, definePolicy, rules) |
Template | Ownership rules are one line of inline code in a route |
http/openapi.ts |
Template now, library later | One entry per resource in a template. With many resources, a helper from route validators is worth it, or Void adds OpenAPI |
http/app.ts, http/validate.ts |
Drop | Void’s router and validator |
http/errors.ts, http/error-page.ts |
Drop | Hono’s HTTPException (Void returns its response) and Void’s error pages |
http/rate-limit.ts |
Drop | Better Auth limits auth routes; a binding where an app needs more |
auth/middleware.ts, auth/fields.ts, auth/schema.ts, auth/password-shim.ts |
Drop | Void’s auth middleware and default table names. The shim no longer helps: Void’s own runtime imports node:crypto and turns on nodejs_compat |
Found on the way:
- Import Drizzle from
void/dbandvoid/schema-d1, notdrizzle-orm. Void depends on its owndrizzle-orm, installed against a newer@cloudflare/workers-typesthan the app’s, and the two copies’ types don’t mix (tscfails ondb.select().from(table)). - Better Auth accepts an invitation only from a verified email. With no mail
set up, no one can join an organization; the test marks the address
verified in D1. A real starter needs
void/emailfor verification mail. - Void has no OpenAPI.
z.toJSONSchemacovers bodies and parameters. - Query strings arrive as strings:
limitneedsz.coerce. - A handler that returns
nullanswers 204.
What changed outside the starter
Section titled “What changed outside the starter”pnpm-workspace.yaml:allowBuilds: better-sqlite3: false(pnpm refuses to install with an unanswered build script).tools/lint/eslint.config.js: ignore**/.void/**(Void’s generated code).packages/core: external auth (question 2).pnpm-lock.yaml.
Files in starters/minimal-void
Section titled “Files in starters/minimal-void”| File | Replaces | Does |
|---|---|---|
void.config.ts |
cloudflare.config.ts |
Bindings with our names, vars, observability, D1 migrations_dir, the JOBS producer and DLQ consumer |
vite.config.ts |
same | voidPlugin() instead of cloudflare(); the password-shim and tsyringe aliases stay |
env.ts |
.dev.vars.example |
BETTER_AUTH_SECRET and the optional OAuth app secrets |
auth.ts |
Flarestack’s createAuth |
Void’s Better Auth: email + password, PBKDF2, organizations, admin, our column names |
middleware/00.flarestack.ts |
the Worker entry | Hands /api/* but /api/auth to server.worker() |
db/schema.ts, db/migrations/ |
drizzle.config.ts, migrations/ |
The app’s tables and Flarestack’s non-auth ones; Void adds the auth tables |
crons/schedules.ts |
scheduled export |
Forwards to flarestack’s scheduled handler |
queues/jobs.ts, queues/jobs-dlq.ts, queues/_consume.ts |
queue export |
Forward to flarestack’s queue handler with a stand-in ExecutionContext |
durable-objects/realtime.ts |
exports.durableObject |
Room as REALTIME |
routes/spike/enqueue.dev.ts |
Dev only: puts a job on JOBS |
|
test/api.test.ts, test/types.ts |
test/api.test.ts |
The built Worker in Wrangler’s test harness; the hc type check |
void.lock.json |
Written by Void; it says to commit it |