Technical guide
25

Technical guide · Published Sep 10, 2025

Building a Modern Full-Stack Web Application: Part 1

The React Router, TypeScript, PostgreSQL, and authentication stack I chose after using LLMs to prototype the alternatives.

A field-guide drawing of a vintage camping lantern
In this article3 sections

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:

LayerMain choicesWhy they earned a place
FrontendReact Router v7, React 19.1.1, TypeScript 5.9.2Route-level data loading with generated types
UITailwind CSS v4.1.13, Catalyst, Headless UI, Heroicons, MotionAccessible primitives without building every interaction from scratch
Backend and dataNode.js v24+, PostgreSQL on Neon, Drizzle ORM v0.44.5Serverless Postgres with type-safe queries
Authenticationbetter-auth v1.3.9, Zod v4.1.5Password, passkey, OAuth, sessions, and validation in one layer
DevelopmentVite v7.1.5, ESLint, Prettier, Vitest, Playwright, StorybookA 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?

What was missing?

Thanks. That gives me something concrete to check.