src/components; page-specific blocks live in src/features.src/lib, pure and unit tested.src/app — routes, layouts, metadata, error boundaries, SEO files.src/config — stable site information, links and navigation.src/content — source of truth for case studies and notes, plus the schemas that gate them.src/features — domain blocks for home, work and contact.src/components — design primitives with no domain knowledge.src/lib — small pure utilities.app may import anything. features may import components, content, config and lib.
components never imports features or app. content never imports UI.
projectSchema in src/content/schema.ts is the important file. It forces every case study into
the same shape:
| Field | Purpose |
|---|---|
context |
The real problem, before any solution is mentioned. |
architecture |
A diagram id plus alt text. Validated against the diagram registry. |
decisions[] |
choice, alternative, rationale — as separate fields. |
cicd[] |
How it was delivered and operated. |
results[] |
Label and value pairs. Evidence, not adjectives. |
decisions is modelled as a structured array rather than prose on purpose. Prose lets
hand-waving hide; being forced to name what you chose and what you rejected and why does not.
It also means a future case study cannot quietly skip the reasoning — the schema rejects it.
Architecture diagrams are React components emitting inline SVG, in
src/components/visual/architecture-diagram.tsx, keyed by the diagramIds enum in the schema.
Inline rather than image files so the strokes and labels use currentColor and follow the theme
toggle exactly. A .svg loaded through <img> cannot see the page theme; a PNG would need two
copies. This costs nothing at runtime: no diagram library, no extra request, no client JavaScript.
Because the ids are an enum, a case study referencing a diagram that does not exist fails the type check rather than rendering an empty box.
output: "export" — the whole site is prerendered to static HTML in out/ and served by GitHub
Pages behind the matheuspaiva.dev custom domain. No server, no database, no runtime.
Two consequences worth remembering:
next.config.mjs. See docs/security.md.generateStaticParams() returns an empty array fails the build. The blog
emits one unlinked, no-index placeholder while there are no posts; as soon as content is
published, generateStaticParams() emits only the real article routes.The Docker image builds the same static output and serves it with nginx, which is where the security headers do get applied.
The static site sends small, non-blocking events to the Django collector configured through
NEXT_PUBLIC_ANALYTICS_ENDPOINT. Production uses
https://admin.matheuspaiva.dev/api/analytics/collect/; failure of that service never affects
navigation or rendering.
The collector accepts only configured origins, ignores common bots, rate-limits writes and stores neither IP addresses nor raw browser identifiers. Visitor and 30-minute session tokens are created in the browser and HMACed by the server before storage. Referrers are reduced to their hostname.
Detailed events are retained for 90 days. Run python manage.py prune_analytics daily: it writes
permanent daily totals before removing expired detail. The profile screen links to an opt-out flag
stored on matheuspaiva.dev, so the owner’s browser does not count while normal visitors remain
anonymous.
Content is authored in the control panel (admin/, admin-ui/) and stored in Postgres. The
build never reaches the panel or the database:
control panel -> Postgres -> pnpm content:pull -> src/content/content.json -> build
pnpm content:pull authenticates against the panel, calls /api/export/, and writes
src/content/content.json. That file is committed. src/content/index.ts parses it once with
contentSchema, and projects.ts, posts.ts and config/site.ts read from there.
Three properties fall out of that direction:
The one value that stays outside the panel is NEXT_PUBLIC_SITE_URL: it describes where the
build is deployed, not what is being said, and it has to be known before any content is read.