Writing Technical guide

Web Development · 3 min read

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.

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

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?

What was missing?

Thanks for the feedback.