Spinning up a modern full-stack TypeScript project usually means wiring together half a dozen tools by hand: a frontend framework, a backend server, an API layer, an ORM, authentication, linting, and a monorepo build system. Each one has its own config, and the seams between them are exactly where type safety leaks away.
Better-T-Stack removes that friction. It is an open-source CLI (create-better-t-stack) that scaffolds an end-to-end type-safe monorepo where your database schema, API contracts, and frontend code all share one set of types. Change a column in your schema and TypeScript flags the broken frontend component before you ever run the app.
In this tutorial you will scaffold a complete app — a Hono backend, a tRPC API, a Drizzle ORM data layer, Better Auth, and a TanStack Router frontend — then wire up a small feature end to end to prove the type safety is real.
Prerequisites
Before starting, make sure you have:
- Bun 1.1+ installed (
curl -fsSL https://bun.sh/install | bash) — Better-T-Stack uses Bun as the default runtime and package manager. Node.js 20+ also works. - Basic TypeScript knowledge — generics and inference will come up.
- Familiarity with React — the default frontend is React with TanStack Router.
- A code editor with the TypeScript language server (VS Code recommended).
- Roughly 30 minutes.
What You'll Build
By the end you will have a running monorepo with two apps:
apps/web— a React frontend (TanStack Router + Tailwind + shadcn/ui)apps/server— a Hono server exposing a tRPC API backed by Drizzle and SQLite
You will add a notes table, expose a typed create and list procedure, and consume them from a React component — all without writing a single REST endpoint or manually-typed fetch call.
Step 1: Scaffold the Project
Better-T-Stack ships a single command. You can run it interactively and answer prompts, or pass flags for a reproducible setup. Let's start interactive so you can see every choice:
bun create better-t-stack@latestThe CLI walks you through frontend, backend, API layer, database, ORM, authentication, and addons. For this tutorial pick:
- Frontend: React → TanStack Router
- Backend: Hono
- API: tRPC
- Runtime: Bun
- Database: SQLite
- ORM: Drizzle
- Auth: Better Auth
- Addons: Turborepo, Biome
- Example: Todo (gives you a reference feature to learn from)
Prefer a one-shot, reproducible command? Pass the choices as flags instead of answering prompts:
bun create better-t-stack@latest my-app \
--frontend tanstack-router \
--backend hono \
--api trpc \
--runtime bun \
--database sqlite \
--orm drizzle \
--auth \
--addons turborepo biome \
--examples todo \
--installThe --install flag installs dependencies automatically. When it finishes, you have a complete monorepo. There is no vendor lock-in here — every dependency is a standard open-source package you could have installed yourself; the CLI just removes the wiring tedium.
You can also use the visual Stack Builder at better-t-stack.dev/new to click through your choices and copy the generated command. It is the fastest way to explore the full option matrix without memorizing flag names.
Step 2: Understand the Generated Structure
Move into the project and look at the layout:
cd my-appA typical generated monorepo looks like this:
my-app/
├── apps/
│ ├── web/ # React frontend
│ │ ├── src/
│ │ │ ├── routes/ # TanStack Router routes
│ │ │ ├── components/
│ │ │ └── utils/trpc.ts # typed tRPC client
│ │ └── package.json
│ └── server/ # Hono backend
│ ├── src/
│ │ ├── routers/ # tRPC routers
│ │ ├── db/ # Drizzle schema + client
│ │ ├── lib/auth.ts # Better Auth config
│ │ └── index.ts # Hono entry
│ └── package.json
├── turbo.json # Turborepo pipeline
├── biome.json # linter/formatter config
└── package.json # workspace root
The key idea: apps/server exports the type of its tRPC router, and apps/web imports that type to build a fully typed client. Nothing crosses the network untyped.
Step 3: Run the App for the First Time
Set up the database and start both apps. Better-T-Stack wires Turborepo so a single command runs the whole monorepo:
# Push the Drizzle schema to your local SQLite database
bun run db:push
# Start web + server together
bun devOpen the printed URL (usually http://localhost:3001 for the web app). If you selected the Todo example, you'll see a working list backed by the API. The server runs on its own port (commonly 3000) and the web app proxies tRPC calls to it.
If bun dev reports a missing environment file, copy the generated .env.example to .env inside apps/server first. Better Auth needs a BETTER_AUTH_SECRET — generate one with openssl rand -base64 32.
Step 4: Add a Database Table with Drizzle
Now let's add our own feature. Open the Drizzle schema in apps/server/src/db/schema and define a notes table:
// apps/server/src/db/schema/notes.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const notes = sqliteTable("notes", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
body: text("body").notNull().default(""),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
});
// Inferred types you can reuse everywhere
export type Note = typeof notes.$inferSelect;
export type NewNote = typeof notes.$inferInsert;Make sure the schema is re-exported from the schema barrel (often apps/server/src/db/schema/index.ts), then push the change to SQLite:
bun run db:pushDrizzle reads your TypeScript definition and syncs the table — no separate SQL migration file to hand-write for local iteration. The Note and NewNote types are now the single source of truth for this entity.
Step 5: Expose a Typed tRPC Procedure
A tRPC procedure is just a function with a validated input and a typed output. Create a router for notes:
// apps/server/src/routers/notes.ts
import { z } from "zod";
import { desc } from "drizzle-orm";
import { router, publicProcedure } from "../lib/trpc";
import { db } from "../db";
import { notes } from "../db/schema/notes";
export const notesRouter = router({
list: publicProcedure.query(async () => {
return db.select().from(notes).orderBy(desc(notes.createdAt));
}),
create: publicProcedure
.input(
z.object({
title: z.string().min(1, "Title is required").max(120),
body: z.string().max(2000).optional(),
}),
)
.mutation(async ({ input }) => {
const [created] = await db
.insert(notes)
.values({ title: input.title, body: input.body ?? "" })
.returning();
return created;
}),
});The z.object(...) schema validates input at runtime and infers the input type at compile time. If a caller sends the wrong shape, tRPC rejects it before your handler runs.
Now register the router in the root app router:
// apps/server/src/routers/index.ts
import { router } from "../lib/trpc";
import { notesRouter } from "./notes";
export const appRouter = router({
notes: notesRouter,
// ...other routers (todo, etc.)
});
// This exported TYPE is what the frontend consumes
export type AppRouter = typeof appRouter;That export type AppRouter line is the whole trick. It is erased at build time — it ships zero runtime code — yet it carries the full shape of every procedure across the package boundary.
Step 6: Consume the API from React
On the frontend, the generated tRPC client already imports AppRouter. You call procedures like local async functions, with full autocomplete and return-type inference:
// apps/web/src/routes/notes.tsx
import { useState } from "react";
import { trpc } from "../utils/trpc";
export function NotesPage() {
const utils = trpc.useUtils();
const notesQuery = trpc.notes.list.useQuery();
const createNote = trpc.notes.create.useMutation({
onSuccess: () => utils.notes.list.invalidate(),
});
const [title, setTitle] = useState("");
return (
<div className="mx-auto max-w-xl p-6">
<form
onSubmit={(e) => {
e.preventDefault();
if (!title.trim()) return;
createNote.mutate({ title });
setTitle("");
}}
className="flex gap-2"
>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="New note title"
className="flex-1 rounded border px-3 py-2"
/>
<button className="rounded bg-indigo-600 px-4 py-2 text-white">
Add
</button>
</form>
<ul className="mt-6 space-y-2">
{notesQuery.data?.map((note) => (
<li key={note.id} className="rounded border p-3">
<strong>{note.title}</strong>
<p className="text-sm text-gray-500">{note.body}</p>
</li>
))}
</ul>
</div>
);
}Notice what you did not do: no fetch, no manually-typed response interface, no API base URL strings, no as casts. Hover note in your editor and TypeScript reports Note — the exact type inferred back in Step 4. This is the payoff of the stack.
Step 7: Prove the Type Safety Is Real
Here is the test that makes the whole thing worthwhile. Go back to the schema and rename title to heading:
// apps/server/src/db/schema/notes.ts
heading: text("heading").notNull(), // was: titleWithout touching anything else, run a type check across the monorepo:
bun run check-typesTypeScript fails the build and points at both the server procedure (which still references notes.title) and the React component (which reads note.title). The mismatch is caught at compile time, in the editor, before a single request is made. That feedback loop — across the network boundary, in one type check — is the core value Better-T-Stack delivers. Revert the rename to title once you have seen the errors.
Step 8: Add Authentication with Better Auth
Because you scaffolded with --auth, Better Auth is already configured in apps/server/src/lib/auth.ts and mounted in the Hono app. To gate a procedure behind a session, swap publicProcedure for the generated protectedProcedure:
import { protectedProcedure } from "../lib/trpc";
create: protectedProcedure
.input(z.object({ title: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
// ctx.session.user is typed and guaranteed to exist here
return db.insert(notes).values({
title: input.title,
body: "",
}).returning();
});The protectedProcedure middleware checks the session and throws an UNAUTHORIZED error if the user is not signed in, so ctx.session is non-null inside your handler. Better Auth provides the sign-in, sign-up, and session endpoints; the frontend client (authClient) gives you typed signIn, signUp, and useSession helpers out of the box.
Testing Your Implementation
Verify the full loop works:
- Run
bun run db:pushand confirm thenotestable is created (open the SQLite file withbun run db:studioif Drizzle Studio is wired up). - Run
bun devand open the web app. - Add a note through the form and confirm it appears in the list and survives a page refresh (it is persisted in SQLite).
- Run
bun run check-types— it should pass with zero errors when your schema and consumers agree.
Troubleshooting
bun: command not found — Install Bun, then restart your shell so the ~/.bun/bin path is loaded.
tRPC calls return CORS errors — Confirm the server's CORS origin matches your web app's URL. Better-T-Stack sets a CORS_ORIGIN env var in apps/server/.env; update it if you changed ports.
BETTER_AUTH_SECRET is not defined — Generate one with openssl rand -base64 32 and add it to apps/server/.env.
Schema changes not reflected — Re-run bun run db:push. For SQLite during early development this rewrites the local table; for production use drizzle-kit generate to produce versioned migrations instead.
Types not updating in the editor — Restart the TypeScript server (in VS Code: Command Palette → "TypeScript: Restart TS Server"). The monorepo shares types via project references, which occasionally need a nudge.
Next Steps
- Swap SQLite for PostgreSQL with a hosted provider (Neon, Supabase, or Turso) by re-scaffolding with
--database postgres --db-setup neon, or adjust your Drizzle config manually. - Add oRPC instead of tRPC if you want OpenAPI generation alongside type safety — Better-T-Stack supports it as a drop-in API choice.
- Deploy
apps/serverto Cloudflare Workers by selecting the Workers runtime; the CLI configures Wrangler for you. - Explore our related guides: Drizzle ORM with Next.js, tRPC with the Next.js App Router, Better Auth authentication, and building REST APIs with Hono and Bun.
Conclusion
Better-T-Stack is not a framework you have to learn — it is a scaffolding tool that assembles best-in-class, independent libraries into a coherent, end-to-end type-safe monorepo. The real win is the feedback loop you saw in Step 7: a change in your database schema surfaces as a compile error in your React component, across the network boundary, before you run anything. You picked your stack à la carte, kept zero vendor lock-in, and shipped a working full-stack feature in a single sitting. From here, every layer — frontend, API, ORM, auth — is a standard tool you can extend or replace independently.
Sources: create-better-t-stack on GitHub, Better-T-Stack docs, npm package.