Source for tom.segbers.de, a personal portfolio site.
Static, no auth, no database, no tracking. Content lives as Markdown files under
content/. The site is built with the Next.js App Router and statically exported
on deploy.
- Tech Stack
- Getting Started
- Available Scripts
- Project Structure
- Content
- Testing
- Deployment
- Conventions
- Environment Variables
- License
- Next.js 14 with the App Router, static export
- TypeScript in
strictmode,noUncheckedIndexedAccesson,unknownoverany, Zod at boundaries - Tailwind CSS with
@tailwindcss/typographyfor Markdown body text - Flowbite React for UI primitives (navbar, footer, buttons, alerts, tooltips)
- Nix flake + direnv for a reproducible dev shell (Node 22, pnpm, git, Playwright Chromium libs)
- pnpm as the package manager
(
packageManager: pnpm@10.15.1) - Jest with React Testing Library for unit and component tests
- Playwright for end-to-end tests
- Storybook for component visual smoke tests
- Resend for the contact form server action
- gray-matter for Markdown frontmatter parsing
- await-to-js for explicit async error tuples
- T3 Env for type-safe environment variables
git clone <repo-url> TomSegbers.de
cd TomSegbers.de
direnv allow # activates the Nix devShell with Node 22 + pnpm
pnpm install --frozen-lockfile
pnpm dev # http://localhost:3000direnv allow loads the Nix flake declared in flake.nix and .envrc, which
provides nodejs_22, pnpm, git, and the shared libraries Playwright
Chromium needs. If you skip Nix, install Node.js >= 18.17 and pnpm yourself.
Note on the Nix devShell: it exports PNPM_HOME and npm_config_prefix as
null, which crashes pnpm 11. When running pnpm inside the devShell, unset
them first:
unset PNPM_HOME npm_config_prefixA pre-commit hook enforces
Conventional Commits via
.pre-commit-config.yaml. It is installed automatically by pre-commit install -t commit-msg once pre-commit is available in your shell.
From package.json:
| Script | Purpose |
|---|---|
pnpm dev |
Start the dev server with colorized output |
pnpm build |
Production build, generates static pages |
pnpm start |
Run the production build locally |
pnpm lint |
Run next lint (ESLint) |
pnpm lint:fix |
Auto-fix lint errors |
pnpm prettier |
Check formatting |
pnpm prettier:fix |
Apply Prettier fixes |
pnpm test |
Run Jest unit and component tests |
pnpm e2e:headless |
Run Playwright e2e tests in headless mode |
pnpm e2e:ui |
Run Playwright e2e tests with the interactive UI |
pnpm storybook |
Start Storybook on port 6006 |
pnpm build-storybook |
Build a static Storybook bundle |
pnpm test-storybook |
Run Storybook smoke tests against the built bundle |
pnpm analyze |
Build with @next/bundle-analyzer enabled |
pnpm coupling-graph |
Render module dependency graph to graph.svg via Madge |
pnpm format |
Apply Prettier to *.ts, *.tsx, *.md |
For e2e tests, Playwright needs a Chromium browser. Inside the Nix devShell,
set PLAYWRIGHT_BROWSERS_PATH to the local cache so Playwright finds a
matching build:
export PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/ms-playwright.
├── AGENTS.md # Agent-facing project rules and conventions
├── README.md
├── LICENSE
├── flake.nix # Nix devShell: Node 22, pnpm, git, Chromium libs
├── flake.lock
├── .envrc # direnv entry: `use flake`
├── package.json
├── pnpm-lock.yaml
├── next.config.mjs
├── env.mjs # T3 Env schema (server + client vars)
├── tsconfig.json
├── jest.config.js
├── jest.setup.js
├── playwright.config.ts
├── tailwind.config.js
├── postcss.config.js
├── prettier.config.js
├── .eslintrc.js
├── git-conventional-commits.yaml
├── .pre-commit-config.yaml
├── app/
│ ├── layout.tsx # Root layout: Header, Footer, metadata, viewport
│ ├── page.tsx # Home: hero, projects marquee, blog teasers
│ ├── not-found.tsx
│ ├── about/page.tsx
│ ├── contact/page.tsx # Contact form ("use client")
│ ├── blog/page.tsx # Blog listing
│ ├── blog/entry/[slug]/page.tsx # Blog detail, generateStaticParams
│ ├── projects/page.tsx # Projects listing
│ └── projects/entry/[slug]/page.tsx # Project detail, generateStaticParams
├── components/
│ ├── ArticleTeaser/ # Blog post card
│ ├── Button/
│ ├── Footer/
│ ├── Header/ # Flowbite Navbar
│ ├── PersonTeaser/
│ ├── ProjectTeaser/ # Project card with technology icons
│ ├── TimelineEntry/
│ └── Tooltip/
├── lib/
│ ├── markdown.ts # Shared gray-matter + Zod frontmatter reader
│ ├── blog.tsx # getAllBlogPosts(): wraps markdown reader
│ ├── projects.tsx # getAllProjects(): wraps markdown reader
│ ├── icon-map.ts # Maps technology strings to icon paths
│ ├── sendEmail.tsx # Server action, Resend contact form
│ ├── markdown.test.ts
│ └── sendEmail.test.tsx
├── content/
│ ├── blog/*.md # Blog posts
│ └── projects/*.md # Project entries
├── e2e/
│ ├── home.spec.ts
│ ├── static-routes.spec.ts
│ └── dynamic-content.spec.ts
├── tests/ # Jest test helpers
├── public/
│ ├── img/ # Photos and the OG logo
│ └── icon/ # SVG icons used in components
├── .storybook/
│ ├── main.ts
│ └── preview.ts
├── styles/
│ └── tailwind.css # Tailwind entry, imports Flowbite
└── scripts/
└── check-project-frontmatter.js # Validates project Markdown frontmatter
All site content is Markdown with YAML frontmatter, loaded at build time by
lib/markdown.ts. No CMS, no database, no runtime writes. Files live in two
collections:
content/blog/for blog posts, served under/blog/entry/<slug>content/projects/for project entries, served under/projects/entry/<slug>
The slug is the file name without the .md extension. lib/blog.tsx and
lib/projects.tsx are thin wrappers around the shared reader.
Each Markdown file requires this frontmatter:
---
title: Story Title
articleDate: 2024-01-15
articleContent: Short teaser shown on listings and OG descriptions.
authorImgSrc: /img/example.png
authorName: Tom Segbers
# Projects only:
technologies:
- TypeScript
- Next.js
---
Body of the post or project write-up in Markdown.lib/markdown.ts parses frontmatter with gray-matter and validates it
through a Zod schema. Every field has a .default() and .catch() fallback so
malformed frontmatter never breaks the build; missing values fall back to
placeholder strings. Entries are sorted by articleDate descending.
The body is rendered as Markdown on the detail pages via react-markdown, and
the @tailwindcss/typography plugin styles the rendered HTML through the
prose class family.
Three layers, each with a dedicated runner:
Jest for unit and component logic (pnpm test). Component tests use
React Testing Library and assert behavior through accessible queries. The
shared Markdown reader and the contact form server action have their own
focused suites in lib/.
Playwright for end-to-end coverage (pnpm e2e:headless). Specs live in
e2e/ and assert post-hydration DOM, not just server HTML. The contact form
spec sets E2E_CONTACT_FORM_SUCCESS=true so sendEmail succeeds without
calling Resend. Playwright's webServer boots next dev on port 3030
automatically.
Storybook for visual smoke (pnpm build-storybook && pnpm test-storybook).
The test runner verifies each story renders without errors. Stories are
written in TSX; MDX stories are not supported by the smoke runner.
Deployed on Vercel via the GitHub integration. A push
to main triggers a build and a production deploy; rollbacks are performed
through the Vercel dashboard.
pnpm build runs next build and statically generates all pages; the build
currently emits 36 static routes. The only server-side runtime code is the
contact form server action, which calls the Resend API. The site has no
middleware, no instrumentation hook, no OpenTelemetry, and no custom API
routes beyond the Next.js health-check rewrites in next.config.mjs.
For local production verification:
pnpm build
pnpm start # http://localhost:3000-
Commit messages follow Conventional Commits and are enforced by the
commit-msghook in.pre-commit-config.yaml. -
Async error handling uses await-to-js. Async functions that can fail return
[error, result]tuples rather than throwing. Example from the codebase:import to from "await-to-js" const [error] = await to(resend.emails.send(emailData)) if (error) { console.error(error) return false }
-
Tailwind class merging uses
tailwind-merge, and component variants useclass-variance-authority. See theButtoncomponent for the pattern. -
Type safety is strict.
tsconfig.jsonhasstrictandnoUncheckedIndexedAccessenabled.@total-typescript/ts-resettightens built-in types. Runtime validation at trust boundaries (Markdown frontmatter, env vars, contact form input) uses Zod schemas. -
Project rules for agents and contributors live in AGENTS.md. Read it before non-trivial changes.
Environment variables are validated at build time with
T3 Env. The schema lives in env.mjs:
export const env = createEnv({
server: {
RESEND_API_KEY: z.string().optional(),
E2E_CONTACT_FORM_SUCCESS: z.enum(["true"]).optional(),
ANALYZE: z.enum(["true", "false"]).optional().transform((v) => v === "true"),
},
client: {},
runtimeEnv: {
RESEND_API_KEY: process.env.RESEND_API_KEY,
E2E_CONTACT_FORM_SUCCESS: process.env.E2E_CONTACT_FORM_SUCCESS,
ANALYZE: process.env.ANALYZE,
},
})RESEND_API_KEY is optional so the build passes in CI and local dev without
a key. It is required in production for the contact form to actually deliver
mail. Set it in .env.local (gitignored) or as a Vercel environment variable.
E2E_CONTACT_FORM_SUCCESS is a test-only override that makes the contact
form server action short-circuit success without hitting Resend.
MIT. See LICENSE for details.