Skip to content
bruno.lombardi
← All posts
  • 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.

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.

LayerChoiceWhy
FrameworkNext.js 16, App RouterServer Components by default, static output
UIReact 19Small client islands where needed
LanguageTypeScript, strictTranslations and content are typed
StylingTailwind CSS v4Design tokens live in plain CSS variables
MotionMotionSprings and scroll-linked values without the weight
ContentMDX in the repositoryPosts 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>:

src/app/globals.css
: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:

src/i18n/dictionaries/pt-BR.ts
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:

src/i18n/rich.tsx
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.

  1. Keep the locale in React state instead of the URL, and load the other dictionary when the switcher is hovered.
  2. Fade the page out for 140 ms with the Web Animations API, which only animates opacity and transform.
  3. Commit the new text synchronously with flushSync, while nothing is visible.
  4. Fade back in, and update <html lang>, the title and the address bar with history.replaceState.
src/i18n/provider.tsx
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 transform and opacity. 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 only

How 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/mdx
Why 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#

  1. Shiki reuses the TextMate grammars behind VS Code, so highlighting here matches what you see in your editor. ↩