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
Drizzle carries schema types into queries. It does not validate every runtime input:
// 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.
Validate inputs before a query Prepared-statement placeholders do not replace input validation. Parse external values before executing a query; PostgreSQL can reject invalid values at runtime.
Authentication with Multiple Methods
The authentication layer uses better-auth: email/password, WebAuthn/passkeys, OAuth, and HTTP-only cookie sessions.
import { passkey } from "better-auth/plugins/passkey";
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!,
},
},
plugins: [passkey()],
});
The passkey import above is for better-auth 1.3.9, as documented in that version’s plugin guide. New installations should follow the documentation for their installed version.
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>;
}
Keep schema types aligned 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 query construction and prepared statements; the PostgreSQL driver and Neon connection endpoint owned 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.
Was this useful?
Thanks for the feedback.