Skip to main content
Luiz Pansarini
← Back to blog

Building this portfolio

· 7 min read

  • #nextjs
  • #tailwind
  • #i18n
  • #mdx
  • #biome
  • #accessibility
Contents
  1. Why I rebuilt it
  2. Stack rationale
  3. Bilingual via next-intl
  4. Jedi/Sith theming
  5. MDX pipeline reuse
  6. The tooling
  7. What got cut

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 OKLCH colors. The v4 shift to CSS-first config — no tailwind.config.js, only a @theme block in globals.css — made the Jedi/Sith palette natural to express as semantic tokens. OKLCH gave perceptually uniform color: the Sith saber-red landing at oklch(54% 0.21 28) rather than an arbitrary HSL was a contrast story (next section). 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 Sith-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.

Jedi/Sith theming

next-themes does the persistence and the no-flash bootstrap. Tailwind v4 does the variable resolution via @custom-variant dark (&:where(.dark, .dark *)) in globals.css, replacing v3's darkMode: 'class' config that no longer exists.

The interesting story is the contrast fix. The first Sith saber-red landed at oklch(58% 0.21 28) — visually iconic on a high-end display, but text-primary on the body background measured 3.67:1 against axe-core's contrast probe. WCAG 2.1 AA wants 4.5:1 for body text. The matrix run failed on the very first green build.

Two paths forward. Shift the saber tone darker, or change the usage pattern. We did both. The Sith primary moved to oklch(54% 0.21 28) for the toggle and buttons, where the direction is bg-primary text-primary-foreground — that pair is AA-clean by construction. For inline accents like the FeaturedProjectsTeaser "View all" link, the pattern became text-foreground decoration-primary decoration-2 underline-offset-4. Same saber visual, AA-clean foreground.

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.

Aurebesh as a decorative-only font with a fallback story. The first attempt to download FT Aurebesh from a font distribution site hit a distribution gate — the URL returned 60KB of HTML form-page instead of a binary. The fallback, AurekFonts/Aurebesh_Rodian (MIT-licensed), works fine and ships at 6.8KB woff2. The license and provenance are committed to public/fonts/LICENSE-aurebesh.txt. The lesson: 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.

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.

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.

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.