Skip to content

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.

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) to createServer().
  • Auth is Void’s root auth.ts: Better Auth with email + password, PBKDF2, organizations and admin. Flarestack reads the user with getSession() from void/auth and builds no Better Auth of its own. API keys, MCP, passkeys, two-factor and OAuth are left out for now.
  • void db generate writes every migration, auth tables included, with Flarestack’s column names.
  • Crons, queues and the Room run through Void’s crons/, queues/ and durable-objects/ with our binding names.
  • pnpm test builds 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:

  1. A real deploy. void deploy has no dry run. Wrangler’s dry run on Void’s build resolves every binding, but nothing has run on an account.
  2. Preview stages and APP_URL. Void has no named environments; each stage would be its own project config.
  3. Queues. Void passes consumers no ExecutionContext and configures no dead-letter queue; the workarounds are in question 4.
  4. API keys and MCP, when they come back, go into auth.ts as Better Auth plugins. /api/keys and /mcp then 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 returns fetch, queue and scheduled, with the Room export, for the plain plugin to serve. That’s the same result as Void’s middleware, crons/, queues/ and durable-objects/ files. It needs no ExecutionContext stand-in, keeps the dead-letter queue as configured, keeps the class name Room, and is 21 KiB gzip smaller.
  • Install. None of the 82 extra lockfile packages.
  • Tests. flarestack/testing and @cloudflare/vitest-plugin work today; with Void, the vitest plugin can’t load next to voidPlugin().
  • What Void does that a slim CLI would have to keep doing:
    • void db migrate against the local database vite dev serves. 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.json for resource IDs. cf deploy doesn’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.ts 369, db.ts 161, dev.ts 48, remote.ts 47, config.ts 50, util.ts 166). The rest are the generators (create, make, templates), which Void doesn’t replace either. The infrastructure part can shrink to cf deploy + cf d1 migrations apply, plus the parts Cloudflare doesn’t cover: stages, APP_URL, the secrets file and finding the local D1.

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.

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.

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.

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 iterations

What 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.

  • Packaging. One flarestack package 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.
  • resource as 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/core goes. 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.

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's
export 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/tasks lists it → POST /mcp tools/list returns list_tasks.
  • /api/openapi.json 200; /mcp without credentials 401 (flarestack’s JSON-RPC error); /__void/ready 200 (Void’s own paths still work).
  • With auth: { oauth: true } (temporarily), /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp return flarestack’s metadata.
  • hc types: test/types.ts builds hc<AppType> and calls client.api.tasks.$get; a @ts-expect-error on a missing route holds. pnpm check passes. AppType doesn’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_als compatibility flag on its own.
  • Void refuses any dotenv file besides .env, .dev.vars.example included. Secrets move to env.ts (defineEnv) and void 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 generate stopped on a column-rename prompt and vite dev logged Cannot add required auth fields "createdAt", "updatedAt" to table "user".

So Flarestack’s auth was dropped instead. What the starter does now:

  • auth.ts is Void’s. ...defaults (Void’s base URL, secret, D1 database, /api/auth), plus PBKDF2 from flarestack/auth/password, the cookie cache, and the organization (our roles) and admin plugins.
  • Same column names as Flarestack. Each table’s fields (and each plugin’s schema) come from fieldsOf(table) (flarestack/auth/fields), which maps Flarestack’s Drizzle fields to its column names. Void’s generated migration then has email_verified, organization_id, active_organization_id and so on, so Flarestack’s own queries on user and member read the same tables.
  • One session read. createServer({ auth: { session: async () => getSession() } }). With auth.session set, Flarestack builds no Better Auth. It also serves no /api/auth, /.well-known or /api/keys (404), and OAuth is off. server.worker({ mcp: false }) leaves /mcp out.

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.
  • sessionMiddleware takes session as an alternative to auth.
  • WorkerConfig.mcp may be false.
  • flarestack/auth/fields: fieldsOf(table).
  • SessionResult is exported.

Notes:

  • Void calls the defineAuth function per request with { defaults, request, env }, and also in Node at build and void db generate time 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 Origin isn’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) to db/migrations/, plus db/schema.ts re-exporting src/server/schema.ts.
  • void db status → 0000_init.sql local: applied, 0001_oauth.sql local: applied. (vite dev had applied both on first start.)
  • void db generate → No schema changes, nothing to migrate.
  • Adding a column → void db generate wrote db/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 generate has no --name.
  • Remote migrations need migrations_dir on the D1 binding (set in void.config.ts).
  • better-sqlite3 is a Void dependency, but its build script is denied (allowBuilds: better-sqlite3: false) and every local void db command 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.ts exports cron = ["0 8 * * *"] and forwards to flarestack’s scheduled. The build has triggers.crons: ["0 8 * * *"]. POST /__void/scheduled → {"ok":true}, and flarestack logged no task registered for cron (its handler ran). Every expression must be listed in a crons/ file, apart from the app’s schedules.
  • Queues. queues/jobs.ts forwards to flarestack’s queue. A JOBS.send() from a dev route was delivered: flarestack logged unknown job; dropping message, then QUEUE jobs 1/1. Gaps:
    • Void calls consumers with (batch, env) and no ExecutionContext. Flarestack’s jobs use waitUntil, so queues/_consume.ts passes a stand-in that waits for them.
    • Void-managed queues get no dead-letter queue, which flarestack’s failed_jobs needs. Declaring the consumer in void.config.ts (dead_letter_queue: "jobs-dlq") adds a second consumer entry for jobs next 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_DLQ producers.
  • Room. durable-objects/realtime.ts is defineDurableObject({ name: "realtime", class: Room }) → binding REALTIME. 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 as RealtimeDurableObject with migration tag void-durable-realtime-v1, not Room, so a Worker already deployed with Room needs 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 test runs vite build, then test/api.test.ts starts dist/ssr/wrangler.json with Wrangler’s createTestHarness (the same harness flarestack/testing uses) 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 in account.password and sign-in, organization + task, a user outside the organization gets 403, anonymous gets 401, /api/keys and /mcp 404, OpenAPI. @cloudflare/vitest-plugin is gone from the starter.
  • flarestack/testing’s startApp still reads cloudflare.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.js plus chunks and dist/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) lists REALTIME (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, against 1947.51 KiB / gzip: 494.40 KiB for starters/minimal today, 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-7 has no equivalent.
  • APP_URL from the workers.dev URL. It’s a plain worker.vars value now.
  • flarestack check (startup profiling) and secrets from the shell environment (now void secret put, validated against env.ts).
  • Gained: staged deploys with a readiness check and rollback, void.lock.json for 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.yaml packages: 821 on main, 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 use 2.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), and wrangler@4.151.0. The last one we added to the starter: Void’s Vite plugin refuses to load with our wrangler@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/minimal 628 MB, starters/minimal-void 708 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 minimumReleaseAge without exclusions. On the first try pnpm reported no matching @cloudflare/workers-types@^5.20261014.1 from stale metadata; a plain pnpm install resolved 5.20261015.1.
  • engines.node >=24.21.0 isn’t enforced: build and void db status also ran on 24.18. CI uses node-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.ts gives them to the organization plugin.
  • lib/tenant.ts: requireMember(c, { task: ["update"] }) reads the session’s activeOrganizationId, 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 through z.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/db and void/schema-d1, not drizzle-orm. Void depends on its own drizzle-orm, installed against a newer @cloudflare/workers-types than the app’s, and the two copies’ types don’t mix (tsc fails on db.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/email for verification mail.
  • Void has no OpenAPI. z.toJSONSchema covers bodies and parameters.
  • Query strings arrive as strings: limit needs z.coerce.
  • A handler that returns null answers 204.
  • 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.
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