Personal website and blog for Alex Swensen. Built with Next.js (App Router), React 19, Tailwind CSS v4, and TypeScript. Deployed on Vercel.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| UI | React 19, Tailwind CSS v4, shadcn/ui (Radix primitives) |
| Auth | Better Auth (email+password, Google, Discord, TOTP 2FA) |
| Database ORM | Drizzle ORM |
| Database (prod) | Neon (serverless Postgres) |
| Database (local dev) | Vanilla Postgres via Docker |
| Animations | Motion (Framer Motion) |
| Blog content | Markdown files in posts/ with gray-matter front-matter |
| Testing | Playwright (E2E) |
| Component dev | Storybook 10 |
| Deployment | Vercel (with Vercel Analytics + Speed Insights) |
posts/ # Markdown blog posts (front-matter: title, excerpt, date, tags, image)
scripts/ # Utility scripts (db-reset.mjs)
src/
app/ # Next.js App Router pages and route handlers
api/
auth/[...all]/ # Better Auth catch-all handler
comments/ # GET + POST comments API
auth/ # Sign-in, sign-up, two-factor pages
blog/[slug]/ # Blog post page (includes CommentSection)
components/ # Shared React components (shadcn/ui + custom)
config/ # Site-wide config (site.ts)
data/ # Static data (resume-data.ts)
db/ # Drizzle client (index.ts) and schema/
schema/
auth.ts # Better Auth tables (user, session, account, verification, two_factor)
comments.ts # comments table (FK → user.id)
index.ts # re-exports all schema
lib/ # Utilities
auth.ts # Better Auth server instance (server-only)
auth-client.ts # Better Auth browser client
posts.ts # Reads/parses markdown posts
utils.ts
styles/ # Global and markdown CSS
stories/ # Storybook stories mirroring src/components/
tests/ # Playwright E2E tests
drizzle/ # Drizzle migration SQL files and meta snapshots
Authentication is handled by Better Auth.
- Server instance:
src/lib/auth.ts— importsserver-only, uses the Drizzle adapter against the shareddbpool - Client instance:
src/lib/auth-client.ts—createAuthClientfrombetter-auth/reactwithtwoFactorClientplugin - API handler:
src/app/api/auth/[...all]/route.ts—toNextJsHandler(auth)
Enabled features:
- Email + password
- Google OAuth (
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET) - Discord OAuth (
DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET) - TOTP two-factor authentication with backup codes (
twoFactor()plugin)
Each blog post page includes <CommentSection slug={slug} /> (client component in src/components/CommentSection.tsx). It fetches from GET /api/comments?slug=<slug> and posts to POST /api/comments. POST requires a valid Better Auth session (checked via auth.api.getSession). Unauthenticated visitors see a sign-in/register prompt instead of the form.
The project uses Drizzle ORM with the Neon serverless driver (@neondatabase/serverless). The driver communicates over WebSockets, which lets it work in Vercel's Edge/serverless runtime.
Although the Neon driver is used, the schema and all queries are standard PostgreSQL — no Neon-specific extensions or APIs are used. In local development the Neon driver is transparently proxied to a plain Postgres container via a WebSocket proxy (neon-proxy). This means:
- You can swap
DATABASE_URLto point at any standard Postgres instance. drizzle-kitCLI commands (db:generate,db:migrate,db:push,db:studio) use a direct TCP connection to Postgres, not the WebSocket proxy.- The singleton
Poolinsrc/db/index.tsis re-used across Next.js hot reloads to prevent connection exhaustion.
Start the stack with Docker Compose:
docker compose up -dThis starts two services:
db—postgres:18-alpineon port5432neon-proxy— Neon's open-sourcewsproxyon port4444, forwarding WebSocket connections from the app todb:5432
Copy .env.example to .env and fill in the required values.
| Table | Key columns |
|---|---|
user |
id (text PK), name, email (unique), emailVerified, image, twoFactorEnabled |
session |
id (text PK), token (unique), userId (FK → user), expiresAt |
account |
id (text PK), providerId, accountId, userId (FK → user) |
verification |
id (text PK), identifier, value, expiresAt |
two_factor |
id (text PK), secret, backupCodes, userId (FK → user) |
comments |
id (uuid PK), postSlug, userId (FK → user), authorName, body, approved, createdAt |
pnpm db:generate # generate a new migration from schema changes
pnpm db:migrate # apply pending migrations
pnpm db:push # push schema directly (no migration file, dev only)
pnpm db:studio # open Drizzle Studio UI
pnpm db:reset # ⚠️ drop ALL tables + drizzle schema (prompts for confirmation)Posts are Markdown files in posts/. Each file has YAML front-matter:
---
title: Post Title
excerpt: Short description
date: YYYY-MM-DD
tags: [tag1, tag2]
image: /img/post-banners/image.png # optional
---src/lib/posts.ts reads all .md files at build time using React's cache() for deduplication. The slug is derived from the filename (minus extension). The blog renders at /blog and /blog/[slug].
pnpm dev # Next.js dev server (Turbopack)
pnpm build # Production build
pnpm start # Production server
pnpm lint # ESLint
pnpm format # Prettier
pnpm storybook # Storybook dev server on :6006
pnpm test:e2e # Playwright tests (headless)
pnpm test:e2e:ui # Playwright tests (UI mode)See .env.example:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=app
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app
BETTER_AUTH_SECRET= # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DATABASE_URL and the BETTER_AUTH_* variables are consumed by the app at runtime. The OAuth variables are only needed if you want social sign-in to work.
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack) |
| UI | React 19, Tailwind CSS v4, shadcn/ui (Radix primitives) |
| Database ORM | Drizzle ORM |
| Database (prod) | Neon (serverless Postgres) |
| Database (local dev) | Vanilla Postgres via Docker |
| Animations | Motion (Framer Motion) |
| Blog content | Markdown files in posts/ with gray-matter front-matter |
| Testing | Playwright (E2E) |
| Component dev | Storybook 10 |
| Deployment | Vercel (with Vercel Analytics + Speed Insights) |
posts/ # Markdown blog posts (front-matter: title, excerpt, date, tags, image)
src/
app/ # Next.js App Router pages and route handlers
components/ # Shared React components (shadcn/ui + custom)
config/ # Site-wide config (site.ts)
data/ # Static data (resume-data.ts)
db/ # Drizzle client and schema
lib/ # Utilities (posts.ts reads/parses markdown, utils.ts)
styles/ # Global and markdown CSS
stories/ # Storybook stories mirroring src/components/
tests/ # Playwright E2E tests
drizzle/ # Drizzle migration SQL files and meta snapshots
The project uses Drizzle ORM with the Neon serverless driver (@neondatabase/serverless). The driver communicates over WebSockets, which lets it work in Vercel's Edge/serverless runtime.
Although the Neon driver is used, the schema and all queries are standard PostgreSQL — no Neon-specific extensions or APIs are used. In local development the Neon driver is transparently proxied to a plain Postgres container via a WebSocket proxy (neon-proxy). This means:
- You can swap
DATABASE_URLto point at any standard Postgres instance. drizzle-kitCLI commands (db:generate,db:migrate,db:push,db:studio) use a direct TCP connection to Postgres, not the WebSocket proxy.- The singleton
Poolinsrc/db/index.tsis re-used across Next.js hot reloads to prevent connection exhaustion.
Start the stack with Docker Compose:
docker compose up -dThis starts two services:
db—postgres:18-alpineon port5432neon-proxy— Neon's open-sourcewsproxyon port4444, forwarding WebSocket connections from the app todb:5432
Copy .env.example to .env and fill in POSTGRES_PASSWORD at minimum.
| Table | Key columns |
|---|---|
users |
id (uuid PK), name, email (unique) |
comments |
id (uuid PK), postSlug, userId (FK → users), authorName, authorEmail, body, approved, createdAt |
pnpm db:generate # generate a new migration from schema changes
pnpm db:migrate # apply pending migrations
pnpm db:push # push schema directly (no migration file, dev only)
pnpm db:studio # open Drizzle Studio UIPosts are Markdown files in posts/. Each file has YAML front-matter:
---
title: Post Title
excerpt: Short description
date: YYYY-MM-DD
tags: [tag1, tag2]
image: /img/post-banners/image.png # optional
---src/lib/posts.ts reads all .md files at build time using React's cache() for deduplication. The slug is derived from the filename (minus extension). The blog renders at /blog and /blog/[slug].
pnpm dev # Next.js dev server (Turbopack)
pnpm build # Production build
pnpm start # Production server
pnpm lint # ESLint
pnpm format # Prettier
pnpm storybook # Storybook dev server on :6006
pnpm test:e2e # Playwright tests (headless)
pnpm test:e2e:ui # Playwright tests (UI mode)See .env.example:
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=app
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app
DATABASE_URL is the only variable consumed by the app at runtime. In production it points to the Neon connection string.