LLMs made it cheap to prototype several stacks instead of choosing from documentation alone. I settled on React Router v7, TypeScript, PostgreSQL, Drizzle, and better-auth. It is a long list, but one result justified it: TypeScript caught dozens of mistakes while I was building the application.
This first part covers the architecture. Part 2 covers deployment and workflow.
Tech Stack
The stack is easier to understand by responsibility than as one long dependency list:
| Layer | Main choices | Why they earned a place |
|---|---|---|
| Frontend | React Router v7, React 19.1.1, TypeScript 5.9.2 | Route-level data loading with generated types |
| UI | Tailwind CSS v4.1.13, Catalyst, Headless UI, Heroicons, Motion | Accessible primitives without building every interaction from scratch |
| Backend and data | Node.js v24+, PostgreSQL on Neon, Drizzle ORM v0.44.5 | Serverless Postgres with type-safe queries |
| Authentication | better-auth v1.3.9, Zod v4.1.5 | Password, passkey, OAuth, sessions, and validation in one layer |
| Development | Vite v7.1.5, ESLint, Prettier, Vitest, Playwright, Storybook | A short feedback loop from code to browser to test |
Architecture Decisions
Type-Safe Data Layer
Using Drizzle ORM provides full type safety across the entire data layer:
// Type-safe queries with full IntelliSense
const users = await db.query.users.findMany({
where: eq(users.isActive, true),
with: {
profile: true,
settings: true,
},
});
// Prepared statements for performance
const getUserById = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder("id")))
.prepare();
The database schema uses normalized tables with foreign keys, JSON columns for flexible data, and indexes on frequently queried columns.
Warning Drizzle ORM’s
sql.placeholder()prepared statements require exact type matching. A string where the schema expects an integer will fail silently at runtime, not at compile time.
Authentication with Multiple Methods
The authentication layer uses better-auth: email/password, WebAuthn/passkeys, OAuth, and HTTP-only cookie sessions.
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
passkey: {
enabled: true,
},
});
API Architecture with React Router v7 Typegen
React Router v7’s typegen automatically generates route-specific types based on your file structure:
import type { Route } from './+types/app.dashboard._index';
export const meta: Route.MetaFunction = () => {
return [
{ title: 'Dashboard - My App' },
{ name: 'description', content: 'Application dashboard overview' },
];
};
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await requireAuth(request);
const data = await getData(params.id);
return { data, user };
}
export default function Component() {
const { data, user } = useLoaderData<typeof loader>();
return <div>...</div>;
}
Field tip This pattern eliminates separate API routes. Type safety extends from loader arguments to meta functions to client-side data consumption.
What earned its place
Vite kept the feedback loop short. Drizzle handled prepared statements and connection pooling. Better-auth removed authentication boilerplate, and React Router’s generated route types carried loader data through to components without a separate API type layer.
TypeScript caught dozens of potential runtime errors during development. That alone justified the stack choice; the other packages had to earn their place around it.
Continue to Part 2 for deployment, workflows, and performance.
One quick signal
Did this earn your time?
Thanks. That gives me something concrete to check.


