WaFine docs
Project docsResearch & decisions

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.


0. Executive summary

QuestionAnswer
Does it scaffold a Bun + Hono + Drizzle + Better Auth + Next.js monorepo?Yes. Verified by running it.
Is the generated backend hexagonal?No. It is flat-routed: apps/server/src/index.ts + packages/api/src/routers/index.ts. There is no domain layer, no ports, no adapters.
Is it a drop-in for app/bff/?No. It emits a different architecture (workspace monorepo, oRPC/tRPC routers, Drizzle-in-a-package).
RecommendationDo not adopt wholesale. Borrow the auth wiring and the workspace shape; keep the hexagonal BFF. See §4.

1. What bun create better-t-stack@latest actually scaffolds

1.1 Invocation and version

The docs give four equivalent invocations (better-t-stack.dev/docs):

npm create better-t-stack@latest      # npx create-better-t-stack@latest
pnpm create better-t-stack
yarn create better-t-stack
bunx create-better-t-stack            # bun create better-t-stack@latest

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

1.2 Offline behaviour — it needs network

  • 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.)

1.3 Flag-grammar gotchas discovered by running it

These are not obvious from the docs and cost real time:

  1. --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).
  2. 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.
  3. --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.
  4. --dry-run exists and "Validate[s] configuration and target directory without writing files" (docs/cli).

1.4 The default stack (--yes)

bunx create-better-t-stack def --yes --no-install --no-git emitted this reproducibleCommand in bts.jsonc:

--frontend tanstack-router --backend hono --runtime bun --database sqlite --orm drizzle
--api trpc --auth better-auth --payments none --addons turborepo --examples none
--db-setup none --web-deploy none --server-deploy none

So the default is TanStack Router + Hono + SQLite + Drizzle + tRPC + Better Auth + Turborepo — not Next.js. Next.js must be requested explicitly.

1.5 The exact tree for Bun + Hono + Drizzle + Better Auth + Next.js

Command actually run (non-interactive, verified to completion):

bunx create-better-t-stack my-app \
  --frontend next --backend hono --runtime bun \
  --database postgres --orm drizzle --db-setup supabase \
  --auth better-auth --api orpc \
  --payments none --addons none --examples none \
  --web-deploy none --server-deploy none \
  --package-manager bun --no-install --no-git --disable-analytics --no-render-title

The CLI recorded this as reproducibleCommand inside the generated bts.jsonc (verbatim from the generated file):

bun create better-t-stack@latest my-app --frontend next --backend hono --runtime bun
--database postgres --orm drizzle --api orpc --auth better-auth --payments none
--addons none --examples none --db-setup supabase --web-deploy none --server-deploy none
--no-git --package-manager bun --no-install

Real emitted tree (from find my-app -type f, node_modules excluded — this is ground truth, not the docs' schematic):

my-app/
├── package.json                 # Bun workspaces root; "workspaces": ["apps/*","packages/*"] + catalog
├── bts.jsonc                    # CLI metadata + reproducibleCommand
├── bunfig.toml
├── tsconfig.json
├── README.md
├── .gitignore
├── apps/
│   ├── server/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   ├── tsdown.config.ts
│   │   ├── bunfig.toml
│   │   ├── .env / .env.schema / .gitignore
│   │   └── src/
│   │       ├── index.ts         # Hono app: cors, logger, /api/auth/*, oRPC handlers
│   │       ├── context.ts       # createContext(): auth.api.getSession(headers) → {db, session}
│   │       ├── services.ts      # export const db = createDb(ENV); export const auth = createAuth(ENV, db)
│   │       └── env.server.ts    # generated by Varlock
│   └── web/                     # Next.js
│       ├── package.json, next.config.ts, next-env.d.ts, postcss.config.mjs
│       ├── components.json, tsconfig.json, bunfig.toml, AGENTS.md
│       ├── .env / .env.schema / .gitignore
│       └── src/
│           ├── app/
│           │   ├── layout.tsx, page.tsx, favicon.ico
│           │   ├── login/page.tsx
│           │   └── dashboard/{page.tsx,dashboard.tsx}
│           ├── components/{header,loader,mode-toggle,providers,theme-provider,
│           │               sign-in-form,sign-up-form,user-menu}.tsx
│           ├── lib/auth-client.ts
│           ├── utils/orpc.ts
│           └── index.css
└── packages/
    ├── config/{package.json,tsconfig.base.json}          # always present
    ├── api/                                              # @my-app/api
    │   ├── package.json, tsconfig.json, .gitignore
    │   └── src/{index.ts,context.ts,routers/index.ts}
    ├── auth/                                             # @my-app/auth
    │   ├── package.json, tsconfig.json, .gitignore
    │   └── src/index.ts
    ├── db/                                               # @my-app/db
    │   ├── package.json, tsconfig.json, drizzle.config.ts, .gitignore, .env.schema
    │   └── src/{index.ts,config.ts,relations.ts,schema/{index.ts,auth.ts},migrations/.gitkeep}
    └── ui/                                               # shadcn primitives
        ├── package.json, components.json, postcss.config.mjs, tsconfig.json
        └── src/{components/*.tsx, lib/utils.ts, styles/globals.css, hooks/.gitkeep}

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.


2. Which parts FIGHT a hexagonal (ports/adapters) layout

The generator is opinionated toward a flat, framework-first monorepo. Concretely:

Generated fileWhy it fights ports/adapters
packages/api/src/routers/index.tsThe 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.tsDefines 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.tscreateContext() calls auth.api.getSession({ headers }) and returns { db, session }. Session resolution and DB handle are fused at the Hono edge.
apps/server/src/services.tsexport 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.tsOne 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.tscreateDb(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.tsBetter 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.tscreateAuth(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.tsThe 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.tomlThe 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.


3. The real, current auth story

Provider: Better Auth, pinned in the workspace catalog at "better-auth": "1.7.7" (generated root package.json workspaces.catalog). Selected via --auth better-auth; the only alternatives are clerk and none (docs/cli/options).

What gets wired (verified from generated files):

  • packages/auth/src/index.ts:
    import { betterAuth } from "better-auth";
    import { drizzleAdapter } from "@better-auth/drizzle-adapter/relations-v2";
    import * as schema from "@my-app/db/schema/auth";
    export function createAuth(env, database, desktopOrigins = []) {
      return betterAuth({
        database: drizzleAdapter(database, { provider: "pg", schema }),
        trustedOrigins: [env.CORS_ORIGIN, ...desktopOrigins],
        emailAndPassword: { enabled: true },
        secret: env.BETTER_AUTH_SECRET,
        baseURL: env.BETTER_AUTH_URL,
        advanced: { defaultCookieAttributes: { sameSite: "none", secure: true, httpOnly: true } },
        plugins: [],
      });
    }
    export type Session = ReturnType<typeof createAuth>["$Infer"]["Session"];
    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.
  • apps/server/src/index.ts mounts it: app.on(["POST","GET"], "/api/auth/*", async (c) => auth.handler(c.req.raw)).
  • apps/server/src/context.ts resolves sessions per request: auth.api.getSession({ headers: context.req.raw.headers }).
  • packages/api/src/index.ts gates routes with protectedProcedure = publicProcedure.use(requireAuth), throwing ORPCError("UNAUTHORIZED") when !context.session?.user.
  • Frontend: apps/web/src/lib/auth-client.ts + login/page.tsx + dashboard/page.tsx + sign-in-form.tsx / sign-up-form.tsx / user-menu.tsx.

How sessions are stored — two layers:

  1. 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).
  2. 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.


4. Cost / benefit vs. the existing app/bff/

4.1 What already exists (verified in-repo, branch mo/e2e-encryption-scaffold, commit 04e3e75)

app/bff/package.json          @waasel/bff — hono, @orpc/server, drizzle-orm, libsodium-wrappers, zod
app/bff/tsconfig.json         strict, noUncheckedIndexedAccess, paths @/* → src/*
app/bff/src/index.ts
app/bff/src/config/env.ts
app/bff/src/domain/ports/index.ts          ICryptoService, IBlindIndexService, IKeyDerivationService, …
app/bff/src/domain/services/index.ts
app/bff/src/domain/value-objects/index.ts  EncryptedPayload, WrappedKey, KeyVersion
app/bff/src/adapters/http/app.ts
app/bff/src/infrastructure/auth/{middleware,rate-limit}.ts
app/bff/src/infrastructure/crypto/{xchacha20,blind-index,argon2id}.ts
app/bff/src/infrastructure/db/schema/index.ts
app/bff/src/infrastructure/storage/r2-client.ts
app/web/                       @waasel/web — next 15, react 19, @orpc/client, libsodium-wrappers

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.

4.2 What adoption would actually cost

DimensionAssessment
Second architectureYes. 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 rootBTS 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.
AuthBTS 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.
CryptoBTS has zero crypto surface. XChaCha20-Poly1305, Argon2id, blind indexes, envelope key hierarchy — all hand-written here, all absent there. No overlap, no help.
DrizzleBTS 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.
oRPCBoth 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.
MaintenanceBTS 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/CIBTS needs the npm registry on every run (§1.2). The hand-written scaffold needs nothing.

4.3 Recommendation

Do not adopt Better-T-Stack as the project's scaffold. It is a net negative here, for three reasons:

  1. 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.
  2. 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.
  3. It imports a second architecture to fight. The repo would carry app/bff (hexagonal), app/web (Next), and apps/*+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.


5. If adopted anyway: minimal refactor path to app/core + app/adapters/{inbound,outbound}

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.

Step 2 — Break the db-in-context coupling.

  • Delete db: Database from packages/api/src/context.ts (Context becomes { session, tenantId, … }).
  • 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.


6. Explicitly UNVERIFIED

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

7. Source list

Primary:

Local verification:

  • bunx create-better-t-stack@latest --version → 3.44.3
  • Dead-proxy run → ConnectionRefused downloading package manifest create-better-t-stack
  • Full non-interactive generation of the Bun+Hono+Drizzle+Better Auth+Next.js stack; tree and key files read from disk
  • --yes default generation; bts.jsonc reproducibleCommand read
  • Repo: /home/m7r/Work/openwa-oci-infra @ 04e3e75, branch mo/e2e-encryption-scaffold; app/bff/**, app/web/**, docs/04-e2e-encryption.md §§4.1–4.3

On this page