← Blog
Apr 20265 min readnext.jstailwindmdxportfolio

Building sebasab.dev — decisions, tradeoffs, and why I didn't use Webflow

How I built this portfolio from scratch with Next.js App Router, Tailwind v4, and MDX — and the reasoning behind every major library choice.

Every few years I rebuild my portfolio. This time I wanted something I'd actually maintain — not a CMS I'd forget the login for, and not a theme I'd be embarrassed by in six months. Here's how I made the decisions.

Why not Webflow or a hosted builder

The short answer: I didn't want to pay for something I could build in a weekend, and I didn't want to be locked out of my own content. Webflow is genuinely good, but it's optimized for people who don't want to write code. I do. A visual builder would have slowed me down.

More importantly, I wanted the site itself to be a demonstration of how I work — not just a page that says I can code.

Next.js App Router

I've been using Next.js since before the App Router existed, so there was no real debate here. What I specifically wanted was full SSG (static site generation) with zero runtime overhead — every route prerendered at build time, no server costs, deploy to Vercel and forget it.

The App Router made this easy: generateStaticParams for dynamic routes (/blog/[slug], /projects/[slug]), server components as the default, and 'use client' only where I actually need interactivity (hover states, tab filters, scroll-driven animations).

Why not Contentlayer

My original spec called for Contentlayer. I started wiring it up and hit a wall: Contentlayer hasn't shipped a Next.js 15+ compatible release. The repo has open issues going back to 2023, and the workarounds involve pinning Next.js to 14 or patching the package yourself. That's not a foundation I want under a site I'll actually maintain.

The alternative was simpler than expected: gray-matter to parse MDX frontmatter, and next-mdx-remote/rsc to render the body in server components. A single lib/content.ts file with direct fs.readFileSync calls. No schema generation step, no .contentlayer cache directory, no magic. It compiles fast and the mental model is obvious.

The one thing to be careful about: any function that touches fs must stay server-side. Client components can't import from lib/content.ts — they'll get a build error trying to bundle Node.js internals. I solved this by splitting types into lib/types.ts (browser-safe) and keeping all fs logic in lib/content.ts. Client components import the types, never the file reader.

Tailwind v4

Tailwind v4 ships as a CSS-first framework. There's no tailwind.config.ts — configuration happens in CSS via @theme blocks, and plugins load via @plugin directives. The design token system is cleaner: define a custom property once in :root, use it everywhere, and Tailwind generates the utility classes automatically.

@import "tailwindcss";
@plugin "@tailwindcss/typography";
 
:root {
  --bg: #f7f5f0;
  --accent: #D85A30;
  --font-serif: 'Instrument Serif', Georgia, serif;
}

No config file means no migration surface and no framework-specific JavaScript to maintain. The tradeoff is that if you're used to tailwind.config.ts, the mental model shift takes an afternoon. It's worth it.

No UI library

I didn't use shadcn/ui, Radix, or any component library. Not because they're bad — they're excellent — but because this site doesn't need them. A nav, a few cards, a blog post layout, some filter tabs. Building these by hand took less time than configuring a library would have, and the output is lighter.

The rule I use: reach for a library when the problem is complex enough that rolling your own creates meaningful risk. Form validation, accessible modals, date pickers — yes. A link with a hover state — no.

Font and palette choices

Instrument Serif for headings: warm, editorial, slightly old-world. It creates contrast against the utilitarian mono in the labels and the neutral sans in body copy. The combination reads as "someone who thinks about craft."

DM Sans for body and DM Mono for labels, dates, and code. Google Fonts, loaded via next/font/google for automatic optimization.

The palette is warm off-white (#f7f5f0) with a coral accent (#D85A30). Off-white instead of pure white because pure white is cold and flat. The coral came from wanting something that reads as intentional — not a default blue, not a trendy purple.

Content architecture

Everything lives in content/ as MDX files with YAML frontmatter. Four content types: posts, projects, experience, certifications. Each type has a corresponding reader function in lib/content.ts.

content/
  posts/
  projects/
  experience/
  certifications/

MDX gives me portability — these are just text files, no database, no migration needed. If I rebuild the site again in three years, the content moves with me.

Deployment

Vercel. The build runs next build, generates all static pages, and deploys to the CDN. Zero configuration. The next/font/google fonts are bundled at build time so there's no Google Fonts request at runtime.


The whole thing took a weekend. The decisions that took the most time were the ones I overthought: font pairing, palette, the exact whitespace ratios. The technical ones were mostly straightforward — the Contentlayer detour was the only real surprise.

If you're building something similar, the stack I'd recommend: Next.js App Router + gray-matter + next-mdx-remote + Tailwind v4. It's simple enough to understand completely and solid enough to not get in your way.