One platform, two faces.
markus-mildner.com is a unified personal and professional platform: a public website, a blog, and a suite of private productivity tools. It serves two audiences at once — professional visitors, and Markus himself as a daily user of his own tools. The public face (homepage, blog, CV) is expressive and editorial; the private interior (tools, finance, study, settings) is denser and functional. Both share one codebase, design system, and infrastructure.
| Surface | Domain | Character | Auth | Visibility |
|---|---|---|---|---|
| Homepage | markus-mildner.com | Landing & public info | None | Public |
| AI Concierge | markus-mildner.com | On the homepage — no dedicated page | Optional | Public |
| Developer CV | markus-mildner.com/cv | Professional credibility | None | Public |
| Blog | blog.markus-mildner.com | Long-form prose | None | Public |
| Financial Planner | app.markus-mildner.com/finance | Data-focused | Cognito (edge) | Private |
| Study Tools | app.markus-mildner.com/study | Academic workspace | Cognito | Private |
| Content Tools | app.markus-mildner.com/tools | Creative workspace | Cognito | Private |
| Settings | app.markus-mildner.com/settings | Owner control panel | Cognito (owner) | Private |
Six non-negotiable constraints.
Every technical decision is evaluated against these, in order of priority. Anything that contradicts them requires an explicit decision recorded in the guide.
AWS & Terraform are fixed
The entire infrastructure runs on AWS, managed by Terraform. No other cloud. No Vercel, Netlify, or Render. No managed hosting that bypasses the existing IaC.
Hosting cost near zero
Target under €5/month total. No always-on compute, no RDS, no ECS. S3 + CloudFront static hosting is the default serving model.
TypeScript everywhere, strict
All frontend and Lambda code is TypeScript with strict: true. No any. This exists primarily to maximise AI code-generation quality.
AI-assistability is first-class
Folder structures, naming, and component design must be predictable enough that Claude generates correct, on-brand code from a brief prompt and this document.
The design system is law
All visual decisions defer to markus-mildner-design-system.md. No inline colour values, no arbitrary spacing, no off-scale fonts.
Security for future exposure
Even private-only features are built as if they will become public or multi-user. Retrofitting security is harder than designing for it upfront.
The technology stack.
Each choice optimises for AI-generation quality, near-zero cost, and the fixed AWS + Terraform base.
| Layer | Choice | Reason |
|---|---|---|
| Framework | Next.js 15 App Router | Best-in-class AI generation; static export for S3; one model for static + dynamic |
| Language | TypeScript 5 (strict) | Type correctness; dramatically better AI accuracy |
| Styling | Tailwind CSS v4 | Native CSS-variable tokens; atomic utilities; no runtime overhead |
| Primitives | shadcn/ui (reskinned) | Accessible base, fully overridden with Sol del Sur tokens |
| Icons | Phosphor | Warmer than Lucide; regular weight default |
| Monorepo | Turborepo | Build caching; workspace orchestration |
| Compute | Lambda (Node 20) | On-demand; free tier covers personal use |
| API | API Gateway HTTP API | ~70% cheaper than REST API |
| Auth | Cognito | Existing User Pool; App Client per app |
| Database | DynamoDB (on-demand) | Key-value patterns; near-zero cost at scale |
| Secrets | SSM Parameter Store | SecureString; fetched at Lambda init |
| Testing | Vitest · RTL · Playwright | Fast unit, behaviour-based component, cross-browser e2e |
Explicitly rejected
- ✕Vercel / Netlify — bypasses Terraform IaC; higher cost at scale.
- ✕Astro — mixed .astro/.tsx reduces AI generation clarity.
- ✕SSR by default — requires compute. output: 'export' is the default; SSR is opt-in per route.
- ✕RDS / PostgreSQL — always-on; ~€7/month minimum. DynamoDB covers all current patterns.
- ✕Prisma / GraphQL — over-abstraction for this scale. AWS SDK + REST is sufficient.
- ✕CSS Modules / Styled Components — breaks the Tailwind utility model.
- ✕any in TypeScript · Jest · Lucide — banned, replaced by Vitest, superseded by Phosphor.
One Turborepo, one-directional graph.
Apps, shared packages, Lambdas, and Terraform live in a single monorepo. Dependencies flow one way; circular imports fail the build.
apps/
web/ # markus-mildner.com (public + tools)
blog/ # blog.markus-mildner.com (static export)
finance/ # finance.… (SPA, Cognito-gated)
packages/
ui/ # Shared React components (Sol del Sur)
tokens/ # CSS variables + TS constants
config/ # Shared tsconfig, eslint, tailwind, vitest
types/ # Shared interfaces + API contracts
lambdas/
chatbot/ visitor-tracker/ contact-form/
notifier/ config-api/
terraform/ # All AWS infra (modules + main.tf)
content-machine/ # AI blog pipeline
docs/ # design-system.md + platform-guide.md
turbo.json package.json # npm workspaces root
The dependency rule. packages/types never imports from ui or tokens — it is the only package Lambdas consume, and Lambdas must never bundle frontend code. A component starts in apps/web/components and is promoted to packages/ui only when a second app needs it. Over-abstraction is as harmful as duplication.
Static by default, dynamic by exception.
The baseline next.config.ts sets output: 'export' so every app deploys as pure static files to S3. Server compute is opt-in, never the default.
Does this page change per-request?
No → output: 'export' (default). Pure static. S3 serves it.
Yes → Is it an API route?
Yes → Standalone Lambda + API Gateway (not a Next API route)
No → Client-side data fetching (SWR) within a static shell.
force-dynamic only as a last resort.
Server vs Client Components
Default to Server Components. Add 'use client' only at the leaves — never make a whole layout Client because one button needs onClick.
Needs 'use client'
- useState / useEffect / useRef
- Browser APIs (window, document)
- Event handlers (onClick, onChange)
- Browser-context libraries
- Real-time subscriptions
Stays a Server Component
- async data fetching
- Reading props, rendering JSX
- await at component level
- Environment variables
- Static content rendering
Design tokens, in code.
The Sol del Sur tokens live as CSS custom properties in packages/tokens/tokens.css and are bridged into Tailwind v4 via @theme. Every colour, spacing, type, and motion value comes from this file — never hardcoded. This is what makes AI-generated components correct by default.
/* Accent — terracotta / sol */
--color-accent: #C8813A; /* dark: #E09B52 */
--color-teal: #1A7A6E;
--color-canary: #D4A843;
/* Surfaces — warm stone */
--color-bg: #F5F1EA; --color-surface: #FDFAF5;
--color-text: #2A2420; --color-text-muted: #7A7268;
/* Fonts */
--font-serif: 'Cormorant Garamond'; --font-sans: 'Geist';
--font-mono: 'Geist Mono'; --font-serif-long: 'Lora';
/* Spacing · radius · motion */
--space-4: 16px; --radius-btn: 6px; --radius-card: 10px;
--ease-modal: cubic-bezier(0.2, 0, 0, 1); --duration-smooth: 200ms;
This page proves it. assets/sol.css is the static-HTML implementation of exactly these tokens — the same accent #C8813A, the same warm-stone surfaces, the same Cormorant / Geist / Geist Mono system, the same cubic-bezier(0.2,0,0,1) signature curve. After Tailwind's @theme resolves them, classes like bg-surface, text-accent, and rounded-card map straight onto these values.
One component anatomy.
Every shared component in packages/ui follows the same shape — so AI tools generate it correctly every time.
// 1 · named props interface, extends the native element
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
size?: 'compact' | 'default' | 'prominent';
}
// 2 · variant maps as const objects, not inline ternaries
const variantClasses = {
primary: 'bg-accent text-[#FFF5EA] hover:opacity-90',
secondary: 'border border-border-strong hover:bg-surface-2',
} as const;
// 3 · forwardRef on interactive leaves · cn() merges className last
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(...);
- ✓Named exports only — no default exports; the import name must match the component.
- ✓className contract — every component accepts className and merges it last with cn().
- ✓Barrel exports — consumers import from index.ts, never a deep file path.
- ✓Extensible Tag — four built-in variants (sol · teal · canary · neutral); custom categories registered at runtime via config (§11).
Lambdas: thin edge, pure core.
Every Lambda splits HTTP concerns from business logic. index.ts parses, validates, and shapes responses; handler.ts is a pure, fully-testable function. Secrets load once at cold start from SSM.
src/
index.ts # HTTP parsing, validation, response shaping
handler.ts # pure business logic — typed in, typed out
types.ts # input/output types
lib/ # AWS SDK wrappers (ssm.ts, dynamodb.ts)
__tests__/ # handler.test.ts + index.test.ts
| Endpoint | Lambda | Notes |
|---|---|---|
| POST /chat | chatbot | Rate-limited; 4000-char input guard |
| POST /contact | contact-form | Writes S3 leads/, triggers notifier |
| POST /visitor | visitor-tracker | DynamoDB dedup, SHA-256 IP hash |
| GET/PUT /config | config-api | Read public; write owner-only (Cognito) |
| GET /health | — | Mock integration, no Lambda, returns 200 |
All infrastructure is Terraform.
Resources are never provisioned manually. A module encapsulates one reusable pattern; each app is a new instantiation, not a copy-paste. Account IDs, regions, and ARNs are never hardcoded.
Naming · {project}-{purpose}-{year}
| Resource | Example |
|---|---|
| S3 | markus-mildner-web-2026 |
| Lambda | markus-website-chatbot |
| DynamoDB | markus-website-visitors |
| SSM | /markus-website/anthropic-api-key |
CloudFront cache strategy
/*.html → no-cache, no-store, must-revalidate
/_next/static/* → public, max-age=31536000, immutable
/images, /fonts → public, max-age=31536000, immutable
/api/* → no-store
DynamoDB, no ORM — plus a config table.
Direct AWS SDK; types model the DynamoDB shape explicitly. Raw IPs are never stored — always a SHA-256 hash (GDPR). A dedicated markus-website-config table holds runtime settings that change without a deploy: feature flags, tag categories, site metadata, chat model selection.
PK: namespace # "platform" | "chat" | "tags" | "features"
SK: key # "site_title" | "model" | "custom_categories"
value # JSON-serialised string
public # readable without auth?
updated_by # Cognito sub (owner identity)
The /settings page (owner-only) edits each namespace independently, saving on blur with optimistic updates and rollback on error. The four built-in tag variants are never stored here — custom categories are always additive, rendered via the Tag component's dynamicStyle prop.
AI features, structured.
System prompts are TypeScript constants in packages/types/src/prompts.ts — never magic strings scattered across Lambdas. The chatbot streams responses as server-sent events.
export const handler = awslambda.streamifyResponse(async (event, stream) => {
stream.setContentType('text/event-stream');
const res = await anthropic.messages.stream({
model: 'claude-sonnet-4-20250514',
system: SYSTEM_PROMPTS.chatbot,
messages: [{ role: 'user', content: event.body ?? '' }],
});
for await (const chunk of res) /* write text deltas */;
stream.end();
});
Targeted confidence, not coverage.
One developer, personal platform — 100% coverage would waste time. Test what you cannot see at a glance: pure logic, edge cases, error handling, and shared components many surfaces depend on. Don't test that a <div> renders.
| Category | Test type | Why |
|---|---|---|
| Lambda handler.ts | Unit (Vitest) | Pure functions; critical; easy to isolate |
| Input validation | Unit | Missing fields, overlong input, bad JSON |
| packages/ui | Component (RTL) | Shared by many apps; regressions are expensive |
| Auth · homepage · chat | E2E (Playwright) | Critical paths; need a real browser |
| Page-specific sections | Not tested | Low reuse; visually obvious; high churn |
Deploy on push, secure by design.
GitHub Actions deploys on push to main. Tests, type-check, and lint gate every deploy; workflow_dispatch allows a manual redeploy from the GitHub mobile app.
push → main ──▶ test job # turbo type-check lint test --filter=web…
│
▼
deploy job # turbo build → aws s3 sync → CloudFront invalidate
Security baseline
- ✓Secrets in SSM SecureString, fetched at cold start — never in env vars or code.
- ✓Input guards on every Lambda; length limits prevent prompt injection & runaway cost.
- ✓Config writes require a valid Cognito owner token; an owner-sub check beyond auth.
- ✓Structured JSON logging only — no console.log, no raw IPs, ever.
Naming & the 14 rules.
Conventions are enforced by ESLint and the strict type-checker so AI generation lands consistently. Components PascalCase.tsx, hooks useCamelCase.ts, types no I-prefix, constants SCREAMING_SNAKE_CASE, const-objects instead of enums.
- TypeScript strict always on.
- No any — narrow unknown or model it.
- No hardcoded colour, spacing, or font values.
- No default exports from components.
- Every interactive element has a focus state.
- Every icon-only button has an aria-label.
- Every image has alt text (alt="" if decorative).
- One h1 per page.
- No pill buttons — rounded-btn (6px).
- Geist is the UI font — no Inter / Roboto.
- No console.log in Lambdas — JSON only.
- No raw IPs stored — SHA-256 always.
- Tests run before deploy; a failure blocks it.
- npm audit in CI; high/critical blocks deploy.