R1 — Better-T-Stack: can it scaffold this hexagonal BFF?
Historical (pre-rebrand, 2026-10): written when the product was called WaaSel and the hosts were waasel.* / bouskoura.online. The product is now WAFINE: production hosts wafine.zawia.tech, api.wafine.zawia.tech, docs.wafine.zawia.tech; bouskoura.online is the testing playground. Names and hosts below are kept as written.
Status: research artifact · Date: 2026-10-08
Subject:create-better-t-stack (better-t-stack.dev) vs. the hand-written scaffold at app/bff/Method: primary sources only — the CLI was executed locally, the template repo was cloned and read, the official docs dump (llms-full.txt) was parsed, and npm registry metadata was queried. Every claim below carries a URL; anything not directly verified is marked UNVERIFIED.
The repo README lists bun create better-t-stack@latest as the recommended form (GitHub README).
Current version at time of writing: 3.44.3 — verified from the npm registry (https://registry.npmjs.org/create-better-t-stack, dist-tags.latest = 3.44.3, 542 published versions) and by running bunx create-better-t-stack@latest --version → 3.44.3.
Package name is create-better-t-stack; bin maps create-better-t-stack → dist/cli.mjs; engines.node >= 22.12.0 (npm registry metadata, same URL).
The CLI is a thin wrapper over two workspace packages: @better-t-stack/template-generator and @better-t-stack/types (npm registry dependencies).
bunx create-better-t-stack@latest --version on a warm machine printed Resolving dependencies / Resolved, downloaded and extracted [2] before answering — it resolves from the registry each run.
Forcing a dead proxy (HTTP_PROXY=http://127.0.0.1:9 HTTPS_PROXY=http://127.0.0.1:9) produced:
error: ConnectionRefused downloading package manifest create-better-t-stack.
Conclusion: bunx create-better-t-stack requires network access to the npm registry on every invocation. It is not usable in an air-gapped/offline CI step unless the tarball is pre-vendored into a local registry or the bun install cache. (Verified locally; no doc contradicts it.)
These are not obvious from the docs and cost real time:
--yes cannot be combined with stack flags. Running
... --yes --database postgres --orm drizzle --auth better-auth --frontend next --backend hono --runtime bun --api orpc --db-setup supabase
errors:
ERROR Cannot combine --yes with core stack configuration flags: --database, --orm, --auth, --frontend, --dbSetup, --backend, --runtime, --api. The --yes flag uses default configuration.
So --yes is only for the default stack; a specific stack must be passed flag-by-flag (and then every remaining prompt must be satisfied, or the CLI hangs on an unsettled top-level await).
Every prompt must be closed. With stack flags supplied but --payments, --addons, --examples, --web-deploy, --server-deploy omitted, the CLI stopped at the payments prompt and then at the web-deployment prompt. It only completed once all of --payments none --addons none --examples none --web-deploy none --server-deploy none were passed.
--db-setup supabase still prompts for Automatic vs Manual provider setup — UNVERIFIED whether a flag suppresses that prompt; I did not find one in the docs' option list.
--dry-run exists and "Validate[s] configuration and target directory without writing files" (docs/cli).
Cross-check against the official structure doc (better-t-stack.dev/docs/project-structure): it states the root has apps/*, packages/*, package.json, bts.jsonc, and packages/config always present; turbo.json only with the Turborepo addon; pnpm-workspace.yaml/bunfig.toml/.npmrc per package manager. It also documents packages/api (when --api ≠ none), packages/auth (when --auth ≠ none), packages/db (when database+ORM selected). The doc's schematic matches the real tree.
Note the doc's Hono section is out of date: it shows only apps/server/src/routers/index.ts + src/index.ts, whereas the real output puts routers in packages/api/src/routers/index.ts and keeps only index.ts, context.ts, services.ts in apps/server/src. Trust the emitted tree over the doc's file listing.
The generator is opinionated toward a flat, framework-first monorepo. Concretely:
Generated file
Why it fights ports/adapters
packages/api/src/routers/index.ts
The use-case layer is the router. appRouter is a literal object of publicProcedure/protectedProcedure handlers. Business logic has nowhere to live except inside transport handlers. There is no domain/ and no application/ equivalent.
packages/api/src/index.ts
Defines o = os.$context<Context>(), publicProcedure, protectedProcedure — i.e. auth is a transport concern baked into the procedure builder, not a domain port.
packages/api/src/context.ts
`Context = { session: Session
apps/server/src/context.ts
createContext() calls auth.api.getSession({ headers }) and returns { db, session }. Session resolution and DB handle are fused at the Hono edge.
apps/server/src/services.ts
export const db = createDb(ENV); export const auth = createAuth(ENV, db); — module-level singletons, not injected dependencies. Hard to swap, hard to test, no composition root.
apps/server/src/index.ts
One file does CORS, logging, auth mounting, oRPC RPC handler, OpenAPI handler, and GET /. No separation between HTTP adapter and app wiring.
packages/db/src/index.ts
createDb(env) returns a raw Drizzle instance; Database = ReturnType<typeof createDb>. There is no repository interface — the ORM is the data port.
packages/db/src/schema/auth.ts
Better Auth's tables (user, session, account, verification) live in the same schema namespace as your domain tables (schema/index.ts re-exports ./auth and ./todo). Auth persistence is not isolated behind an adapter.
packages/auth/src/index.ts
createAuth(env, database, ...) takes the raw Database and calls drizzleAdapter(database, { provider: "pg", schema }). Auth is coupled to the ORM at the package boundary.
apps/web/src/utils/orpc.ts
The frontend imports the server's router type directly (AppRouterClient) for end-to-end type safety — a deliberate coupling that a hexagonal core would normally keep behind a published contract.
bts.jsonc, turbo.json/nx.json, bunfig.toml
The CLI's own metadata + task-runner config assume the apps/*+packages/* shape. create-better-t-stack add reads bts.jsonc to mutate the project later (docs/bts-config).
Verdict: there is no hexagonal seam anywhere in the generated output. The architecture is router → context → db, with auth as middleware. Adopting it as-is means deleting the ports/adapters idea, not extending it.
Provider: Better Auth, pinned in the workspace catalog at "better-auth": "1.7.7" (generated root package.jsonworkspaces.catalog). Selected via --auth better-auth; the only alternatives are clerk and none (docs/cli/options).
Note the sameSite: "none"; secure: true cookie defaults — these are set because the generator assumes a cross-origin web app (Vercel web ↔ separate server). For a same-origin BFF this is a real security posture change to review.
Server-side session table (the source of truth). The generated Drizzle schema packages/db/src/schema/auth.ts defines:
session(id PK, expiresAt, token UNIQUE, createdAt, updatedAt, ipAddress, userAgent, userId → user.id ON DELETE CASCADE) with index("session_userId_idx").
plus user, account, and verification tables, and authRelations via defineRelationsPart.
This matches Better Auth's documented core schema — the official docs state the session table holds id, token, userId, expiresAt, ipAddress, userAgent and that "the session is stored in a cookie and is sent to the server on every request", with the session_token cookie as the server-side session identifier (Better Auth — Session Management).
Cookie. The session_token cookie carries the identifier; the generated code sets httpOnly, secure, sameSite: "none". Better Auth also documents an optional session.cookieCache that writes a separate short-lived session_data cookie — not enabled by the generated config (the generated betterAuth({...}) has no session key).
Default expiry: Better Auth documents 7 days, refreshed when updateAge (default 1 day) is reached (same doc). The generated config does not override expiresIn/updateAge, so the 7-day default applies.
Migrations: the generator ships packages/db/drizzle.config.ts + src/migrations/.gitkeep and root scripts db:push, db:generate, db:migrate, db:studio, plus auth:generate (cd apps/server && bun run auth:generate). Better Auth's own CLI (npx auth@latest generate) is the documented way to regenerate ORM schema (Better Auth — Database).
Relevance to this project:docs/04-e2e-encryption.md §4.3 explicitly says the BFF uses custom auth, not Supabase Auth, because it needs per-tenant key unwrapping tied to sessions, field-level/time-windowed scopes, and audit logging of every unwrap. Better Auth's generated wiring gives you none of that — it gives email+password sessions. The key-unwrapping hook would have to be added as a Better Auth plugin or a custom session callback. UNVERIFIED: whether a Better Auth plugin can cleanly expose "unwrap tenant KEK during session validation" — I did not find a doc confirming a supported hook for that specific flow.
The ports file already declares the hexagonal boundary in code (IBlindIndexService.computeIndex, IKeyDerivationService.deriveKek, ICryptoService.encrypt/decrypt/wrapDek/unwrapDek). None of that exists in Better-T-Stack's output, and none of it is expressible in its router-first shape.
Yes. BTS emits apps/server + packages/{api,auth,db,ui,config}. The repo already has app/bff + app/web. Adopting BTS means either replacing both (losing the hexagonal scaffold) or keeping three shapes (app/bff, app/web, apps/*+packages/*).
Workspace root
BTS does solve the missing-workspace-root problem: it emits a Bun workspaces root with a catalog and --filter scripts. That is genuinely useful and can be copied without adopting the rest.
Auth
BTS gives working Better Auth + Drizzle session tables + login/dashboard pages. That is real, non-trivial boilerplate the project does not have yet. But docs/04-e2e-encryption.md §4.3 mandates custom auth with key-unwrap hooks, so most of it would be rewritten anyway.
Crypto
BTS has zero crypto surface. XChaCha20-Poly1305, Argon2id, blind indexes, envelope key hierarchy — all hand-written here, all absent there. No overlap, no help.
Drizzle
BTS puts Drizzle in packages/db with createDb(env) returning a raw client and auth tables co-located with domain tables. The existing app/bff/src/infrastructure/db/schema/ keeps DB behind the infrastructure layer. Adopting BTS's layout would move DB out of the adapter layer into a shared package — a hexagonal regression.
oRPC
Both use oRPC. BTS's packages/api router shape is compatible in spirit with app/bff/src/adapters/http, but BTS injects db into context, which the ports design forbids.
Maintenance
BTS is at v3.44.3 with 542 releases and a fast cadence (npm registry). create-better-t-stack add mutates the project via bts.jsonc. Committing to that means tracking a moving third-party generator for a codebase whose whole point is a bespoke crypto boundary.
Offline/CI
BTS needs the npm registry on every run (§1.2). The hand-written scaffold needs nothing.
Do not adopt Better-T-Stack as the project's scaffold. It is a net negative here, for three reasons:
Architecture mismatch is total, not partial. The generated backend has no domain layer and no ports; its "application layer" is the router and its "data port" is the raw Drizzle handle in Context. Refactoring it into app/core + app/adapters means deleting most of packages/api, packages/db/src/index.ts, and apps/server/src/{context,services}.ts — i.e. you pay the adoption cost and then throw the value away.
The parts it does solve are already solved or explicitly rejected. The workspace root is a 20-line package.json (copy it). The auth story is the one thing docs/04-e2e-encryption.md §4.3 says must be custom.
It imports a second architecture to fight. The repo would carry app/bff (hexagonal), app/web (Next), andapps/*+packages/* (BTS). Three shapes, one team.
What to borrow instead (surgical, low-risk):
The Bun workspace root shape: "workspaces": { "packages": ["app/*"], "catalog": {...} } with bun run --filter scripts. This fixes the "no workspace root" gap without importing BTS's app layout.
The Better Auth + Drizzle session schema (user/session/account/verification + indexes) as a starting point for the session tables — but place it under app/bff/src/infrastructure/db/schema/ and keep it behind a repository port.
The auth.handler(c.req.raw) mount pattern at /api/auth/* — trivially portable into app/bff/src/adapters/http/app.ts.
The createAuth(env, db) factory signature (dependency-injected rather than a singleton) — this one is actually more hexagonal-friendly than the generated services.ts singleton, and is worth copying.
Do not borrow:packages/api/src/routers/index.ts (router-as-use-case), packages/api/src/context.ts (db in context), apps/server/src/services.ts (module singletons), or the sameSite: "none" cookie defaults without a security review.
Assuming a decision to keep BTS's output and bend it hexagonal, the minimal path is:
Step 1 — Split the pure core out of packages/api.
Create app/core/domain/ (entities, value-objects) and app/core/ports/ (interfaces).
Move the logic currently inline in packages/api/src/routers/index.ts handlers into app/core/services/* (use cases). Routers become thin: parse input → call use case → map output.
packages/api/src/routers/index.ts keeps only healthCheck; every real route delegates.
Define outbound ports in app/core/ports/ (e.g. IMessageRepository, IStorageAdapter, ICryptoService, ISessionRepository).
Implement them in app/adapters/outbound/ wrapping packages/db and the R2 client. packages/db/src/index.ts stays as the driver, but nothing above the adapter imports it.
Step 3 — Make the Hono edge a pure inbound adapter.
Move apps/server/src/index.ts → app/adapters/inbound/http/app.ts; keep only CORS/logger/mounts.
Move apps/server/src/context.ts → app/adapters/inbound/http/context.ts; have it call an injected ISessionResolver port instead of auth.api.getSession directly.
Replace apps/server/src/services.ts singletons with an explicit composition root (app/main.ts or app/adapters/inbound/http/compose.ts) that constructs createDb → repositories → use cases → routers and injects them.
Step 4 — Isolate auth behind a port.
packages/auth/src/index.ts becomes an outbound adapter implementing ISessionResolver / IAuthProvider. Keep createAuth(env, db) but call it only from the composition root.
The key-unwrapping hook required by docs/04-e2e-encryption.md §4.3 attaches here, behind the port, not in the router.
Step 5 — Keep the workspace, drop the duplicate app.
Retain the BTS root package.json (workspaces + catalog + --filter scripts) but point workspaces.packages at app/* and fold app/bff + app/web in. Delete apps/server and packages/api once their contents have moved; keep packages/db and packages/auth only if they are re-expressed as outbound adapters.
Net: roughly the same work as writing the hexagonal BFF from scratch, plus the cost of first understanding BTS's output. That is the core of the §4.3 recommendation.
Whether a flag suppresses the --db-setup supabase Automatic/Manual prompt. No such flag appears in the documented option list (docs/cli/options); I did not exhaustively test.
Whether Better Auth exposes a supported plugin/hook for "unwrap tenant KEK during session validation" (needed by docs/04-e2e-encryption.md §4.3). Not confirmed in Better Auth docs.
The docs' Hono file listing (apps/server/src/routers/index.ts) does not match the emitted tree (packages/api/src/routers/index.ts). I treated the emitted tree as authoritative; the doc may describe an older version.
Exact behaviour of --dry-run output shape (documented as "validate without writing files" but not executed here).
Whether create-better-t-stack add can operate on a project whose layout has been refactored away from apps/*+packages/* — it reads bts.jsonc, so it is plausible but UNVERIFIED.