Contents
Why I rebuilt it
Every Principal-level engineer eventually rebuilds their portfolio. This is the architectural diary of mine.
This is not a "look at my pretty hover effects" post. It is the technical decisions that mattered, the ones that did not, and the ones that did not survive a contrast audit. The portfolio targets two audiences in parallel: recruiters in Brazil and abroad who skim on a phone, and developers who scroll to the source. The constraints those audiences imply — mobile-first, WCAG 2.1 AA, sub-three-second load on a slow connection — drove every stack choice.
The angle is "build with what you show." Same engineer, same stack, same conventions I push at work. The rest of this post is about what and why, not who.
Stack rationale
The locked stack is Next.js 16.2, Tailwind v4, and Shadcn/UI. Three concrete reasons, one per paragraph.
Next.js 16.2 with the App Router and React Server Components. This is what
I ship at Machinery Partner every week. Familiarity is a feature when the goal
is building a portfolio in two evenings, not learning a stack. Turbopack as the
default in 16 means dev cycles in the ~80ms range instead of ~800ms. React 19
landed useActionState and useFormStatus, which removed the case for
react-hook-form: the eventual contact-form layer collapses to a Server
Action plus a Zod schema. No client form library required.
Tailwind v4 with CSS-first tokens. The v4 shift to CSS-first config — no
tailwind.config.js, only a @theme block in globals.css — made the
palette natural to express as semantic tokens. The first palette was written
in OKLCH. The current ink, paper and signal tokens are plain hex, and their
contrast numbers are further down. The build engine moved to Lightning CSS;
the side effect is roughly ten-times faster builds, which compounds across
four axe-core matrix runs in CI.
Shadcn over a component library. Shadcn is not a library. It is a copy-paste catalog of unstyled primitives. That feels regressive until the third time a prebuilt library forces a workaround for a dark-mode contrast bug — at which point owning the source is obvious. The downside is that there is no "Shadcn 1.0 → 2.0 upgrade." The upside is that there is no Shadcn 1.0 → 2.0 upgrade.
Bilingual via next-intl
The bilingual story has three layers: routing, request, and navigation.
Routing. localePrefix: 'always' means every URL carries an /en or /pt
prefix. This is the cleanest SEO story — canonical URLs are unambiguous — and
the simplest mental model. The locale cookie is named NEXT_LOCALE with a
one-year TTL, which survives a recruiter's "I will check back in six months."
Request. A proxy.ts file at the repo root (renamed from middleware.ts
in Next 16) intercepts / and 307-redirects to /en or /pt based on the
Accept-Language header on first visit, the cookie on every visit after.
Brazilian browsers reliably send pt-BR; English-speaking browsers send
en-*; the fallback for ambiguity is en. Note 307 versus 308: next-intl
emits 307 (temporary). The pedantic-correct choice is 308 (permanent), since
the redirect rule never changes — but next-intl's 307 default is fine in
practice.
Navigation. Every internal link goes through src/lib/i18n/navigation.ts,
which re-exports next-intl's locale-aware <Link>. That <Link> toggles
locales while preserving the path. A Biome noRestrictedImports rule blocks
import 'next/link' and import 'next/navigation' anywhere outside that
single file, with one explicit override. The rule caught two regressions
during the Phase 2 review.
The catalog is two flat JSON files: messages/en.json and messages/pt.json.
Until namespace count exceeds about 200 keys, splitting into per-namespace
files is overhead. The build-time gate is full bilingual parity: every key in
EN must exist in PT and vice versa, validated before next build.
The terminal identity
The first version shipped two themes behind a toggle. In October 2026 I
replaced both with one fixed palette: ink #0E0F0C, paper #EEECE6 and
signal blue #2F3BFF, declared as @theme tokens in globals.css. The
palette is scoped to html.site, so only the public pages get it. Admin and
preview still run on the shadcn light/dark tokens through next-themes.
Pages alternate between ink, paper and signal sections. Each section takes a
.surface-ink, .surface-paper or .surface-accent class, and each class
re-points the full shadcn variable set: background, foreground, card,
popover, muted, accent, border, input, primary, ring. The full set matters. A
component that reads a variable the surface did not override inherits the
ink value, and an ink card shows up on a paper section.
Type is two families loaded with next/font/google in src/lib/fonts.ts:
Bricolage Grotesque for display and body, IBM Plex Mono for navigation,
buttons, labels and meta. The header logo is a >pansarini prompt with a
blinking block cursor.
On desktop a Three.js cube field sits behind the hero. It loads lazily and
only when (min-width: 1024px) and (pointer: fine) and (prefers-reduced-motion: no-preference) matches, so phones never download
it. A control in the header pauses every looping animation, the cursor and
the cube field included, which is what WCAG 2.2.2 asks for. Cmd/Ctrl+K opens
the command palette, and typing whoami outside a text field triggers an
easter egg.
I checked the palette with numbers instead of trusting how it looked. Signal
blue on paper measures 5.57:1, and paper text on a signal section is the same
pair, so also 5.57:1. Paper on ink measures 16.27:1. The trap was signal blue
on ink: 2.92:1, under the 3:1 floor for focus rings and far under 4.5:1 for
text. On ink surfaces the ring and the inline badge text point to a softer
blue, #8F96FF, which measures 7.30:1.
The dual-theme syntax-highlight in Phase 3 hit the same wall. Plain
github-light failed AA on yellow-orange tokens — #e36209 against #ffffff
measures 3.48:1. Switched both themes to github-light-high-contrast and
github-dark-high-contrast. Same dual-theme CSS-variable contract;
AA-clean. The takeaway is to pick perceptually uniform color spaces, run the
audit on every theme variant, and fix the surface, not the model.
MDX pipeline reuse
The MDX pipeline shipped in Phase 3 for project case studies. By Phase 4 the
blog needed it too. Two paths: copy-paste the loader, or extract a factory.
Going factory was the obvious right answer — the loader's only parameterized
inputs were the content directory, the Zod schema, and a kind string used
in error messages.
The wrappers — projects.ts and blog.ts — became six-line modules. The
parity check, the Zod validation, and the cache() wrapping carried over
without modification.
The renderer is next-mdx-remote@6 with blockJS: true and
blockDangerousJS: true defaults preserved. Block-JS means MDX cannot run
arbitrary JavaScript: only the closed mdxComponents map (Callout, Note,
Warning, Stat, plus a pre override for the copy-code button) is callable.
Authors write Markdown plus those tags; nothing else executes. This is the
security posture for content authored by a single trusted human, but the
principle scales.
Build-time syntax highlighting via rehype-pretty-code writes
data-theme="github-light-high-contrast github-dark-high-contrast" into every
fenced code block, plus dual-theme CSS variables. A single html.dark class
flip swaps both at zero runtime cost. @next/bundle-analyzer confirms zero
shiki and zero prism JavaScript in client chunks; a homemade
pnpm verify:no-highlighter gate greps .next/static/chunks on every PR to
keep that invariant from regressing.
The tooling
Three smaller decisions worth narrating, because they each saved time later.
Biome over ESLint plus Prettier. Single binary, single config, faster on
every run, fewer configs to keep in sync. The noRestrictedImports rule
replaced ESLint's; the semantic intent is identical. Removed the
eslint.config.mjs from create-next-app's output and never looked back.
Check the font binary before trusting the download. The first version
had a decorative display font, and the first attempt to download it hit a
distribution gate: the URL returned 60KB of HTML form page instead of a
binary. The font is gone, the lesson stayed. When ingesting OFL or MIT
assets, verify the binary header (file path/to/font.ttf) before assuming
the download succeeded. A .html masquerading as .ttf will silently
corrupt your Satori OG image. The OG images now use a Bricolage Grotesque
subset at src/app/fonts/bricolage-800.ttf, with its OFL license committed
next to it as LICENSE-bricolage.txt.
Vercel free-tier reality check. Planning quoted "2,500 events per month" for Web Analytics from older training data. The actual 2026 Hobby tier is 50,000 events per month for Web Analytics plus 10,000 data points per month for Speed Insights. Both are two-to-three orders of magnitude over realistic portfolio traffic. Lesson: re-verify pricing at planning time, not from training-cutoff memory.
What got cut
Five things are deferred to v2, and each has a reason.
A Cmd+K command palette. Nice for power users; recruiters do not use them. Phase 5 if the launch reveals a need. It shipped eleven days later anyway, and it is still there on Cmd/Ctrl+K.
View Transitions on the theme toggle. Chromium and Safari 18+ ship the API, and reduced-motion users get the static fallback automatically. Phase 5 polish — the toggle is already the most ornate UX moment on the site. It did ship in Phase 5, and the October 2026 redesign removed it together with the toggle: with one fixed palette there was nothing left to toggle.
The custom domain pansarini.tech. Bought, not yet pointed. The
*.vercel.app URL is fine for v1 launch and gets Vercel's automatic
preview-noindex headers for free. Phase 5 cutover is a one-line edit to
NEXT_PUBLIC_SITE_URL.
RSS. The blog is one post at launch. RSS makes sense at three. Defer.
Behavioral end-to-end Playwright tests. The axe-core matrix already covers accessibility; the iPhone SE viewport spec already covers mobile overflow. Behavioral tests for locale toggle and theme toggle are lower ROI than shipping more posts. Phase 5 if launch reveals gaps.
The portfolio shipped in four phases over a week — eighteen-plus commits to
main, all green CI. Build with what you show.