Documentation-first portfolio platform built with Next.js, Prisma, and PostgreSQL.
The system is split into:
- Public portfolio pages (
/home,/archive,/tags,/reflections,/about,/contact) - Admin CMS (
/admin/*) for managing content and project lifecycle - DB-backed persistence for projects, reflections, settings, messages, activity, and notifications
- Pluggable media storage (
local,minio,s3, oruploadthing)
- Admin authentication with role-based access (
ADMINwrite actions) - Admin password recovery (email verification code + secure reset flow)
- Full project CRUD with publish/draft/private visibility rules
- Slug-based project routing for public archive pages
- Tags and tools auto-derived from project
toolsarrays (no manual tag manager) - Home/About content editable from admin and rendered on public pages
- Contact form persistence with rate limiting and spam protections
- Activity and notifications persisted in DB
- Media uploads with provider switching (
local<->minio/s3<->uploadthing) - SEO baseline: route metadata, JSON-LD, sitemap, robots, admin noindex headers
- Next.js 15 (App Router)
- React 19 + TypeScript
- Prisma ORM + PostgreSQL
- NextAuth credentials auth
- Tailwind CSS
- Vitest (unit) + Playwright (E2E)
- Docker / Docker Compose
- MinIO (local S3-compatible storage for development)
/home: hero, status strip, featured projects, and system narrative/archive: searchable/filterable public project index/archive/[slug]: project details (public projects only)/tags: tool index derived from project tools/tags/[tool]: projects filtered by selected tool/reflections: public process notes/about: public identity and process story/contact: inbound message form
/admin/login: credentials sign-in/admin/forgot-password: request verification code/admin/reset-password: verify code and reset password/admin/projects: project list + CRUD entry points/admin/projects/new: create project/admin/projects/[slug]/edit: edit project and rich sections/admin/media: upload/manage media assets/admin/about: manage about page content + identity/admin/home: manage home page content/admin/reflections: reflections CRUD/admin/settings: global site and display settings/admin/messages: contact inbox/admin/activity: event log/admin/notifications: admin notifications feed
src/lib/projects/*: project repository + cached public accesssrc/lib/content/*: home/about content repository and defaultssrc/lib/settings/*: site settings repository, defaults, and public snapshotsrc/lib/messages/*: contact message persistencesrc/lib/events/*: activity and notifications persistencesrc/lib/reflections/*: reflections persistence and visibility filteringsrc/lib/storage/*: provider abstraction for upload/delete/public URL
Main models:
User: admin/editor accountsProject: project summary/lifecycle fieldsProjectContent: JSON content document for rich project sectionsProjectAsset: media records linked to projectsReflection: reflection entries with tag metadataSiteSettings: global site identity, links, labels, display preferencesHomePageContent: editable public home contentAboutPageContent: editable public about contentContactMessage: contact form inbox recordsContactRateLimit: anti-spam and rate-limit trackingPasswordResetCode: one-time verification codes for password recoveryActivityEvent: persisted admin activity logAdminNotification: persisted admin notifications
Important enums:
ProjectStatus,ProjectCategory,ProjectVisibilityReflectionTagType,ReflectionVisibilityActivityEventType,AdminNotificationType,AdminNotificationStatus
- Only
PUBLICprojects are returned on public routes. DRAFTandPRIVATEare excluded from public pages.- Admin routes can read/write all visibility states.
- Project URLs are slug-based; slug remains the public identity key.
- Tags are auto-derived from project tools. No manual tag CRUD.
Recent cleanup applied for speed and response efficiency:
- Public project reads are memoized per request via
src/lib/projects/public.ts. - Public settings snapshot is request-cached via
src/lib/settings/public.ts. - Public pages use fully dynamic server rendering (
dynamic = 'force-dynamic') for immediate DB-driven updates. /archivenow receives server-provided initial project data (removed extra clientno-storeAPI fetch).next.config.mjsoptimized for runtime delivery:productionBrowserSourceMaps: falsecompress: truepoweredByHeader: false
Note:
- Build-time lint/type strict blocking is currently relaxed in
next.config.mjs(ignoreBuildErrors,ignoreDuringBuilds) due existing legacy lint debt across many files. Keep this in mind before production hardening.
- Node.js 20+
- npm 10+
- Docker Desktop (recommended for local DB + MinIO)
Create local env files:
cp .env.example .env
cp .env.example .env.localPowerShell:
Copy-Item .env.example .env
Copy-Item .env.example .env.localKey variables:
DATABASE_URLNEXTAUTH_URLNEXTAUTH_SECRETADMIN_EMAILADMIN_PASSWORD- password reset vars (
PASSWORD_RESET_*) - SMTP vars (
SMTP_*,MAIL_BRAND_NAME) STORAGE_PROVIDER(local | minio | s3 | uploadthing)- S3/MinIO vars (
S3_*) - UploadThing vars (
UPLOADTHING_*) - contact guard vars (
CONTACT_*) NEXT_PUBLIC_SITE_URL(canonical metadata/sitemap base)
- Install dependencies:
npm install- Start database (and optionally MinIO):
docker compose up -d db minio minio-init- Apply migrations:
npm run db:migrate- Seed initial data:
npm run db:seed- Start app:
npm run devApp URL: http://localhost:4028
docker compose up --buildor
npm run docker:devnpm run docker:dev:downnpm run docker:prodHealth endpoint:
GET /api/health
Scripts:
npm run db:generate-> Prisma client generationnpm run db:migrate-> create/apply dev migrationnpm run db:migrate:deploy-> apply existing migrationsnpm run db:migrate:status-> migration statusnpm run db:migrate:resolve:init-> baseline olddb pushdatabasenpm run db:bootstrap-> deterministic admin user bootstrapnpm run db:seed-> seed admin + projects + reflections + settings + page content
Deterministic admin bootstrap:
- reads
ADMIN_EMAIL,ADMIN_PASSWORD,ADMIN_USER_ID - always enforces admin role
- optional password reset on seed:
ADMIN_RESET_PASSWORD_ON_SEED=true
- Auth provider: NextAuth credentials (
/admin/login) - Session strategy: JWT
- Middleware protects
/admin/*(except login + forgot/reset password pages) - Admin write actions require
ADMINrole - Forgot password flow:
- Request 6-digit code at
/admin/forgot-password - Verify code + set new password at
/admin/reset-password - Log in normally at
/admin/login
- Request 6-digit code at
- For Gmail SMTP, use
SMTP_HOST=smtp.gmail.comand an App Password inSMTP_PASS(not your normal Gmail password). - Reset flow hardening includes cooldowns, per-fingerprint rate limits, and basic bot/honeypot checks.
Notes:
ADMIN_EMAIL/ADMIN_PASSWORDare bootstrap defaults, not permanent runtime credentials.- You can change admin login email/password from Admin -> Settings -> Admin Access.
Storage provider selected by STORAGE_PROVIDER:
local: files written topublic/uploadsminio: local S3-compatible object storages3: AWS S3 compatible modeuploadthing: UploadThing-managed object storage
Upload strategy:
S3_UPLOAD_MODE=proxy: uploads through app API, easiest for local CORSS3_UPLOAD_MODE=presigned: direct browser upload to bucket using signed URL
UploadThing requirements:
UPLOADTHING_TOKENUPLOADTHING_APP_ID- Optional:
UPLOADTHING_CDN_HOST(defaultufs.sh) - Optional:
UPLOADTHING_APP_ID_LOCATION(subdomainorpath)
MinIO local defaults:
- API:
http://localhost:9000 - Console:
http://localhost:9001
Configured via env:
CONTACT_RATE_LIMIT_WINDOW_SECONDSCONTACT_RATE_LIMIT_MAX_ATTEMPTSCONTACT_RATE_LIMIT_BLOCK_SECONDSCONTACT_MIN_FORM_FILL_SECONDSCONTACT_MAX_LINKSCONTACT_FINGERPRINT_SALT
These are used to reduce abuse and spam submissions.
Implemented:
- Per-route metadata (title, description, canonical, OpenGraph/Twitter)
- Structured data (JSON-LD) on key pages
robots.tsandsitemap.ts- Admin
X-Robots-Tag: noindex, nofollow, noarchive
Sitemap includes:
- top-level public pages
- public project detail pages
- tool tag pages derived from project data
Core:
npm run devnpm run buildnpm run startnpm run serve
Quality:
npm run lintnpm run lint:fixnpm run type-checknpm run testnpm run test:watchnpm run test:e2enpm run test:e2e:headednpm run check
DB:
npm run db:generatenpm run db:migratenpm run db:migrate:deploynpm run db:migrate:statusnpm run db:migrate:resolve:initnpm run db:bootstrapnpm run db:seed
Docker:
npm run docker:devnpm run docker:dev:downnpm run docker:prod
- Unit tests: Vitest
- E2E tests: Playwright (
e2e/) - Suggested local pre-push gate:
npm run type-check
npm run testOptional:
npm run lint
npm run buildprisma/ Prisma schema, migrations, bootstrap, seed
public/ Static files and local uploads
src/app/ App Router routes, layouts, API handlers
src/lib/ Domain repositories and shared services
src/data/ Seed/static source data
e2e/ Playwright tests
docker-compose.yml Local stack: app + db + minio
Dockerfile Multi-stage Next.js image
- Stop existing process/container using that port.
- Then rerun:
docker compose down
docker compose up -d --build- Another container may still be attached.
- Run:
docker ps
docker stop <container-id>
docker compose down- If DB was initialized via
db push, run:
npm run db:migrate:resolve:init
npm run db:migrate:deploy- Verify
STORAGE_PROVIDERand provider URL config (S3_PUBLIC_BASE_URLorUPLOADTHING_*). - For MinIO, ensure bucket is created and public-read policy is applied by
minio-init.
- Use Docker cache and stable network.
- Run local type-check first to catch fast failures:
npm run type-check- Replace default admin credentials
- Set strong
NEXTAUTH_SECRET - Use managed PostgreSQL
- Use real S3 bucket + IAM credentials
- Set
NEXT_PUBLIC_SITE_URLto production domain - Tighten CORS and bucket policies
- Address lint debt and remove build ignore flags
- Add CI gate for migrations + tests + E2E smoke
This repo now includes:
docker-compose.prod.yml(app + postgres + migration job + Caddy)Caddyfile(automatic HTTPS via Let's Encrypt).env.production.example(copy to.env.production)
Before starting containers:
- Point
Arecord for your domain to your OCI VM public IPv4 - Open OCI ingress ports
80and443to0.0.0.0/0 - Keep SSH (
22) restricted to your IP
cp .env.production.example .env.productionSet at minimum in .env.production:
DOMAINACME_EMAILNEXTAUTH_URLNEXT_PUBLIC_SITE_URLPOSTGRES_*andDATABASE_URLNEXTAUTH_SECRETADMIN_EMAILandADMIN_PASSWORD
docker compose -f docker-compose.prod.yml up -d --buildWhat happens:
dbstarts and becomes healthymigraterunsprisma migrate deployonceappstarts on internal port4028caddyserves public80/443and provisions TLS certs
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml logs -f caddy
docker compose -f docker-compose.prod.yml logs -f appOpen:
https://your-domainhttps://your-domain/api/health
docker compose -f docker-compose.prod.yml up -d --build- Do not expose Postgres port publicly.
- Keep
NEXT_PUBLIC_SITE_URLas your final HTTPS URL for canonical/SEO metadata. - If certificate issuance fails, confirm DNS and that ports 80/443 are reachable from the internet.
For contribution workflow details, see CONTRIBUTING.md.