- nextjs
- architecture
- design
How this site is built
The ideas, the stack and the small decisions behind brunolombardi.me: a page that behaves like a trace, solarized themes, live translations and motion that stays smooth on old devices.
▸ Published 5 min readEN
TL;DR
- The home page is a trace: every section is a span of one request moving through a system.
- Next.js 16, React 19, TypeScript, Tailwind v4 and Motion, with a deliberately short dependency list.
- Language switches in place with one cheap fade, and animations only touch transform and opacity.
- Everything is typed: a missing translation fails the build.
On this page
Most portfolios are lists: a headline, a grid of logos, a timeline. I wanted mine to feel like something an engineer made on purpose, so I gave it one rule: the page behaves like a request moving through a system.
That single idea explains most of what you see. The nav on the left is a spine that fills as you scroll. My career is drawn as a distributed trace, with each role as a span. Even the reading time on this blog is a little bar, because a longer read is a longer span.
The stack#
I kept the list short on purpose. The browser only needs Next.js, React and Motion; everything else runs at build time.
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 16, App Router | Server Components by default, static output |
| UI | React 19 | Small client islands where needed |
| Language | TypeScript, strict | Translations and content are typed |
| Styling | Tailwind CSS v4 | Design tokens live in plain CSS variables |
| Motion | Motion | Springs and scroll-linked values without the weight |
| Content | MDX in the repository | Posts are code review friendly |
On the home page, press ↓ or just scroll to start the trace.
Two themes, one set of tokens#
The light theme is a solarized parchment inspired by Noctis Lux, with a warm clay accent. The dark theme is a deep teal. Both are defined once, as CSS variables, and switched by a data-theme attribute on <html>:
:root,
:root[data-theme="light"] {
--bg: #fbf5e6;
--ink: #173f45;
--accent: #d2603a;
}
:root[data-theme="dark"] {
--bg: #04242b;
--ink: #ece5cf;
--accent: #ee8460;
}Tailwind v4 maps those variables to utilities (bg-bg, text-ink, text-accent), so components never contain a raw color. Changing a theme means changing a variable.
Translations are typed#
The site speaks English and Brazilian Portuguese. en.ts defines the Dictionary type, and every other language has to satisfy it:
import type { Dictionary } from "./en";
export const ptBR: Dictionary = {
hero: {
headlines: ["Construo software que continua {simples} conforme cresce."],
},
// ...every other key, or the build fails
};Forget a key and tsc refuses to build. The {word} markup marks the word that gets the accent style, which is handled by a four-line helper:
export function rich(text: string): ReactNode[] {
return text.split(/(\{[^}]+\})/g).map((part, i) =>
part.startsWith("{") ? <span key={i} className="text-accent italic">{part.slice(1, -1)}</span> : part,
);
}Switching language without a reload#
I did not want a language change to feel like navigating. The home page keeps the locale in client state, so the page never remounts, the scroll position survives and the hero does not replay.
- Keep the locale in React state instead of the URL, and load the other dictionary when the switcher is hovered.
- Fade the page out for 140 ms with the Web Animations API, which only animates
opacityandtransform. - Commit the new text synchronously with
flushSync, while nothing is visible. - Fade back in, and update
<html lang>, the title and the address bar withhistory.replaceState.
const out = root.animate(
[{ opacity: 1, transform: "translateY(0)" }, { opacity: 0, transform: "translateY(8px)" }],
{ duration: 140, fill: "forwards" },
);
await out.finished;
flushSync(() => setState({ lang: next, dict: nextDict }));Motion that stays cheap#
An animated site should not punish a five-year-old phone. The rules are simple:
- Animate only
transformandopacity. Width, height and top force layout on every frame. - Keep state changes small. The typed headline lives in its own component, so each keystroke re-renders about fifty nodes instead of the page.
- Pause anything that loops once it leaves the screen.
- Respect
prefers-reduced-motion: no typing, no marquee, and an instant language swap.
- animate={{ width: "100%" }} // layout on every frame
+ animate={{ scaleX: 1 }} // compositor onlyHow this blog works#
Content#
Posts are MDX files in the repository, at src/content/blog/<slug>/en.mdx and pt-BR.mdx. The slug is the same in both languages, and English is the fallback. The frontmatter is validated at build time, so a typo in a date fails the build instead of shipping.
Code blocks#
Highlighting is done by Shiki at build time, with the same grammars VS Code uses1. A dual theme output means the colors follow the site theme with plain CSS, and nothing is highlighted in the browser.
npm install @next/mdx @mdx-js/loader @mdx-js/react @types/mdxWhy not a content framework?
I wanted fewer moving parts. MDX plus a small content layer that reads files, validates them and sorts them is enough for a personal blog, and there is nothing to migrate when a tool changes direction.
Building with an AI pair#
I built this site with Claude Code as a pair programmer. What made it work was an AGENTS.md file that states the rules: use the design tokens, keep copy in the dictionaries, take every fact from my profile and never invent one. Treat the agent like a new teammate and write down what you would tell them on day one.
The best prompt is a good set of constraints.
Footnotes#
-
Shiki reuses the TextMate grammars behind VS Code, so highlighting here matches what you see in your editor. ↩