← Writing

Building This Website

5 min read

Why Build a Personal Website from Scratch?

There are plenty of website builders and templates out there. So why start from zero? For me it came down to one thing: I wanted full control over every detail — the typography, the color palette, the way code blocks render, even the spacing between paragraphs. A personal website is a reflection of how you think about craft, and I wanted mine to feel intentional.

The goal was elegant simplicity — a site that feels minimal without feeling empty. Every design choice should earn its place.

This post walks through the key technology decisions behind this site, with code examples from the actual implementation.

The Stack

Here is a quick overview of the core technologies powering this site:

TechnologyVersionRole
Next.js16.xFull-stack React framework with App Router
React19.xUI rendering with Server Components
TypeScript5.xType safety across the codebase
Tailwind CSS4.xUtility-first styling with CSS-first config
MDX@next/mdxBlog content as React components
Shikivia rehype-pretty-codeBuild-time syntax highlighting

Why Next.js 16

Next.js 16 ships with Turbopack stable by default, React 19 support, and a mature App Router. For a personal site that leans heavily on static generation, the App Router's generateStaticParams makes it trivial to pre-render every blog post at build time. No server required at runtime — just static HTML on a CDN.

The configuration is minimal. Here is the entire next.config.ts:

import type { NextConfig } from "next";
import createMDX from "@next/mdx";
 
const nextConfig: NextConfig = {
  pageExtensions: ["ts", "tsx", "md", "mdx"],
};
 
const withMDX = createMDX({
  options: {
    remarkPlugins: [],
    rehypePlugins: [],
  },
});
 
export default withMDX(nextConfig);

The pageExtensions array tells Next.js to treat .mdx files as valid pages, and createMDX wires up the MDX compiler. The heavy lifting — syntax highlighting, GFM support — happens in a separate compilation step via src/lib/mdx.ts.

Why Tailwind CSS v4

Tailwind v4 introduced CSS-first configuration. Instead of a tailwind.config.js file, design tokens live directly in your CSS using the @theme directive. This means your entire design system is defined in one place — globals.css — rather than split across JavaScript and CSS files.

@import "tailwindcss";
@plugin "@tailwindcss/typography";
 
@custom-variant dark (&:is(.dark *));
 
@theme inline {
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
  --font-heading: var(--font-geist-sans);
  --color-background: var(--background);
  --color-foreground: var(--foreground);
}

The @custom-variant dark line is how dark mode works in v4 — it replaces the old darkMode: 'class' config key. Combined with next-themes, the site respects system preferences and allows manual toggling without a flash of wrong theme on load.

Content as Code with MDX

Every blog post on this site is an .mdx file in the content/blog/ directory. MDX lets you write standard Markdown with embedded React components — the best of both worlds. Posts are version-controlled alongside the code, there is no external CMS to manage, and the content pipeline is just a function call.

How Posts Are Compiled

The compilation pipeline is intentionally simple. Each post's raw MDX string gets compiled server-side using @mdx-js/mdx:

import { compile, run } from "@mdx-js/mdx";
import remarkGfm from "remark-gfm";
import rehypePrettyCode from "rehype-pretty-code";
 
export async function compileMDX(source: string) {
  const compiled = await compile(source, {
    outputFormat: "function-body",
    remarkPlugins: [remarkGfm],
    rehypePlugins: [
      [rehypePrettyCode, {
        theme: { dark: "github-dark", light: "github-light" },
        keepBackground: false,
      }],
    ],
  });
 
  const { default: MDXContent } = await run(
    String(compiled),
    { ...runtime, baseUrl: import.meta.url }
  );
 
  return MDXContent;
}

The remarkGfm plugin enables GitHub Flavored Markdown — tables, strikethrough, task lists. The rehypePrettyCode plugin uses Shiki to tokenize code blocks at build time, so syntax highlighting is baked into the HTML with zero client-side JavaScript.

Custom Components

MDX components are mapped in src/components/mdx/mdx-components.tsx. Every <pre> block gets wrapped with a copy button and language badge. Images go through next/image for automatic optimization and lazy loading.

The beauty of this approach is that you write standard Markdown:

# A heading
 
Some text with `inline code` and a code block:

And the component map transforms it into styled, interactive HTML — no special syntax required.

Design Philosophy

The design follows a simple rule: if it does not serve the content, it does not belong. The typography uses Geist Sans for body text and Geist Mono for code — both are clean, highly legible typefaces that work well at any size.

Color Tokens

The color system uses oklch, a perceptual color space that produces more natural-looking palettes than hex or HSL. Light mode uses warm neutrals, dark mode shifts to cool tones. Every color is defined as a CSS custom property and mapped through Tailwind's theme layer:

:root {
  --background: oklch(0.98 0.005 90);
  --foreground: oklch(0.145 0.02 90);
  --muted: oklch(0.94 0.008 90);
  --muted-foreground: oklch(0.42 0.015 90);
}

Typography

Blog post content uses Tailwind's prose utility class from @tailwindcss/typography. This gives you readable line lengths, proper heading hierarchy, styled blockquotes, and inline code formatting — all without writing custom CSS. The dark:prose-invert variant handles dark mode automatically.

What Is Next

This site is a living project. Upcoming work includes:

  • A portfolio section showcasing selected projects
  • RSS feed generation via a custom route handler
  • Contact form with server-side validation using Resend
  • Performance tuning and Lighthouse score optimization

The codebase is open and the content is version-controlled. Every improvement ships as a commit.