Building a Modern Full-Stack Web Application
I'm currently building a full-stack web application using modern JavaScript/TypeScript technologies. The project is still in active development, but I've established a pretty good foundation with a focus on performance, developer experience, and scalable architecture. LLM has made it really easy for me to prototype frameworks and libraries very easily with relatively low costs. Here's kinda the stack I ended up with.
Tech Stack
Frontend Framework
- React Router v7 - The latest version with built-in server-side rendering, data loading, and routing
- React 19.1.1 - Latest stable React with concurrent features and improved performance
- TypeScript 5.9.2 - Full type safety throughout the application
UI & Styling
- Tailwind CSS v4.1.13 - Utility-first CSS framework with modern features
- Tailwind Plus - Premium Tailwind CSS components and patterns
- Catalyst UI - High-quality React components built on Tailwind CSS
@headlessui/react
v2.2.7 - Unstyled, accessible UI components@heroicons/react
v2.2.0 - Beautiful, consistent icon setmotion
v12.23.12 - Animation library for smooth interactions
Backend & Data
- Node.js v24+ - Latest Node.js with modern JavaScript features
- PostgreSQL - Robust, ACID-compliant relational database
drizzle-orm
v0.44.5 - Type-safe SQL query builder and migration tool- Neon - Serverless PostgreSQL platform
Authentication & Security
better-auth
v1.3.9 - Modern authentication library with WebAuthn, OAuth, and session managementzod
v4.1.5 - Runtime type validation and schema validation
Development & Build Tools
vite
v7.1.5 - Fast build tool and development servereslint
v9.35.0 - Code linting and quality enforcementprettier
v3.6.2 - Code formattingvitest
v3.2.4 - Fast unit testing frameworkplaywright
v1.55.0 - End-to-end testingstorybook
v9.1.5 - Component development and documentation
Architecture Overview
Application Structure
The application follows a modern full-stack architecture with clear separation of concerns:
├── app/ # Main application code
│ ├── components/ # Reusable UI components
│ ├── routes/ # Route handlers and pages
│ ├── models/ # Data access layer
│ ├── db/ # Database schema and connections
│ ├── lib/ # Shared utilities and services
│ ├── utils/ # Helper functions
│ └── hooks/ # Custom React hooks
├── public/ # Static assets
├── migrations/ # Database migration files
└── test-utils/ # Testing utilities
Data Layer Architecture
Database Schema Design
The database schema is designed with performance and maintainability in mind:
- Normalized structure with proper foreign key relationships
- JSON columns for flexible data storage where needed
- Indexes on frequently queried columns
- Unique constraints to maintain data integrity
- Audit fields (createdAt, updatedAt) on all tables
ORM and Query Layer
Using drizzle-orm
provides several advantages over traditional ORMs:
// 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();
const user = await getUserById.execute({ id: userId });
The type safety extends to the entire data layer, catching potential issues at compile time rather than runtime.
Authentication System
The authentication layer uses better-auth
with multiple authentication methods:
- Email/Password - Traditional authentication with secure password handling
- WebAuthn/Passkeys - Passwordless authentication for enhanced security
- OAuth - Social login integration (Google, GitHub, etc.)
- Session Management - Secure HTTP-only cookies with CSRF protection
- MCP Integration - Model Context Protocol support for AI assistants
// Simple auth setup with multiple providers
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 Type Generation
The application uses React Router's built-in data loading patterns with react-router typegen
for full type safety:
// Auto-generated types from route 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' },
];
};
// Server-side data loading with typed arguments
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await requireAuth(request);
const data = await getData(params.id);
return { data, user };
}
// Client-side data consumption with typed loader data
export default function Component() {
const { data, user } = useLoaderData<typeof loader>();
// Data is available immediately on page load with full type safety
return <div>...</div>;
}
React Router v7's typegen
automatically generates route-specific types based on your file structure, providing:
- Type-safe loader arguments -
params
,request
, and context are properly typed - Automatic meta function types - SEO and metadata with IntelliSense
- Loader data inference -
useLoaderData
knows exactly what data is available - Action type safety - Form submissions and mutations are fully typed
This pattern eliminates the need for separate API routes and provides better performance through server-side rendering, all while maintaining complete type safety.
Form Handling
Forms are built using @tanstack/react-form
with zod
validation:
// Type-safe form with validation
const form = useAppForm({
defaultValues: { email: '', password: '' },
validators: {
onChange: ({ value }) => {
const errors = {};
if (!value.email) errors.email = 'Email is required';
if (!value.password) errors.password = 'Password is required';
return { fields: errors };
},
},
onSubmit: async ({ value }) => {
await submitForm(value);
},
});
The integration with zod
provides runtime type checking and automatic TypeScript inference.
Hosting & Deployment
Infrastructure Setup
Fly.io - Primary hosting platform
- Global CDN - Automatic content delivery worldwide
- Auto-scaling - Scale to zero when not in use
- Health checks - Automatic monitoring and restarts
- Database migrations - Run migrations during deployment
Cloudflare - CDN and DNS
- Global edge network - Fast content delivery
- DDoS protection - Built-in security
- SSL certificates - Automatic HTTPS
Database Hosting
Neon - Serverless PostgreSQL
- Auto-scaling - Scale compute resources automatically
- Branching - Database branching for development and staging
- Backups - Automatic point-in-time recovery
- Connection pooling - Efficient connection management
Deployment Configuration
The application is containerized using Docker with multi-stage builds:
FROM node:24-alpine AS base
WORKDIR /app
# Multi-stage build for optimization
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --only=production
FROM base AS build
COPY . .
RUN npm run build
FROM base AS runtime
COPY --from=build /app/build ./build
COPY --from=deps /app/node_modules ./node_modules
EXPOSE 3000
CMD ["npm", "start"]
CI/CD Pipeline
The deployment pipeline ensures code quality and reliability:
- Automated testing on every push
- Type checking and linting with
eslint
- Database migrations during deployment
- Health checks post-deployment
- Rollback capability for failed deployments
Development Workflow
Local Development
# Start development server
npm run dev
# Run tests
npm run test
# Type checking
npm run typecheck
# Code quality checks
npm run check
# Database operations
npm run db:studio # Visual database management
npm run db:seed # Populate with sample data
npm run db:migrate # Apply schema changes
Code Quality
The project maintains high code quality standards:
eslint
- Code linting with React and TypeScript rulesprettier
- Consistent code formatting- TypeScript - Strict type checking with
noUncheckedIndexedAccess
husky
- Git hooks for pre-commit quality checksvitest
- Unit and integration testingplaywright
- End-to-end testing
Database Development
Database development follows a structured approach with drizzle-kit
:
# Schema changes
1. Edit app/db/schema.ts
2. Generate migration: npm run db:generate
3. Apply migration: npm run db:migrate
# Development workflow
npm run db:push # Quick schema sync (development only)
npm run db:studio # Visual database management
npm run db:reset # Clean database reset
The drizzle-kit studio
provides a web-based interface for database inspection and debugging.
Component Architecture
UI Components with Catalyst UI
The application uses Catalyst UI components built on top of Tailwind CSS and @headlessui/react
:
import { Button } from '@catalyst-ui/react'
import { PlusIcon } from '@heroicons/react/16/solid'
export function CreateButton() {
return (
<Button color="indigo" className="gap-2">
<PlusIcon className="size-4" />
Create New
</Button>
)
}
Catalyst UI provides production-ready components with excellent accessibility and consistent design patterns.
Custom Hooks for State Management
The application uses custom hooks for complex state logic:
// Custom hook for data fetching with error handling
export function useAsyncData<T>(fetcher: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetcher()
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { data, loading, error };
}
Performance Considerations
Build Optimization
Using vite
provides several performance benefits:
- Fast HMR - Sub-second hot module replacement
- Tree shaking - Automatic dead code elimination
- Code splitting - Automatic route-based splitting
- Asset optimization - Image compression and modern formats
Database Performance
drizzle-orm
offers performance advantages:
- Prepared statements - Cached query plans
- Connection pooling - Efficient database connections
- Query optimization - Minimal over-fetching
- Type-safe migrations - Prevent runtime errors
Lessons Learned
Building this application taught me several important lessons:
- Modern tooling matters -
vite
,drizzle-orm
, andbetter-auth
significantly improved the development experience - Type safety is crucial - TypeScript caught numerous potential runtime errors
- Component libraries save time - Catalyst UI provided high-quality components without the overhead
- Infrastructure automation - Fly.io's deployment pipeline reduced operational complexity
This architecture demonstrates how modern JavaScript/TypeScript tooling can create maintainable, performant applications with excellent developer experience. The combination of React Router v7, drizzle-orm
, better-auth
, and Fly.io provides a powerful foundation for building full-stack applications in 2025.