Personal portfolio site — research-mode hub for things I build.
Live: (deploy URL goes here after first Vercel deploy)
- Next.js 16 (App Router)
- TypeScript (strict)
- Tailwind CSS v4
- next-themes (light/dark)
- MDX via
next-mdx-remote/rsc - Deployed on Vercel
pnpm install
pnpm devOpen http://localhost:3000.
- Create
content/projects/<slug>.mdx. - Frontmatter required:
--- title: My Project slug: my-project summary: One-line pitch. year: 2026 tech: [Go, Postgres] repo: https://github.com/PS-safe/my-project status: active # active | stable | experimental | archived destination: [portable, personal] # optional featured: true # optional — surfaces on homepage order: 1 # optional — sorts featured ahead of year-desc ---
- Body is plain MDX. Push to main; Vercel auto-deploys.
The shortlink demo on /projects/shortlink reads and writes directly to Neon Postgres. Set this in Vercel → Project → Settings → Environment Variables:
| Variable | Used by | Purpose |
|---|---|---|
DATABASE_URL |
shortlink, otp | Neon Postgres pooled DSN, e.g. postgres://user:pass@…neon.tech/db?sslmode=require |
BREVO_API_KEY |
otp | Brevo (free 300/day) API key for sending verification emails |
OTP_FROM_EMAIL |
otp | Verified sender email in Brevo (e.g. your Gmail) |
OTP_FROM_NAME |
otp | Display name on the From: header |
If a var is missing, the related "Try it" form returns 503 gracefully; everything else still works.
Schemas (apply each in Neon SQL Editor once):
links (shortlink):
CREATE TABLE IF NOT EXISTS links (
slug TEXT PRIMARY KEY,
target TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
click_count BIGINT NOT NULL DEFAULT 0,
last_clicked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_links_created_at ON links (created_at DESC);otps:
CREATE TABLE IF NOT EXISTS otps (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
code_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_otps_email_created ON otps (email, created_at DESC);rate_limit_events (ratelimit demo):
CREATE TABLE IF NOT EXISTS rate_limit_events (
id BIGSERIAL PRIMARY KEY,
key TEXT NOT NULL,
scope TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_rl_key_scope_created
ON rate_limit_events (key, scope, created_at DESC);app/ # routes (home, about, projects, projects/[slug], contact)
components/ # nav, footer, theme toggle, project card, MDX renderer
content/
about.mdx
projects/*.mdx # one file per project
lib/
projects.ts # MDX content loader
about.ts
cn.ts
MIT