Skip to main content
8 min readCalvin Ku

Architecture Overview โ€” How This Site Is Built

A deep dive into the technical architecture: Next.js 15, Contentlayer, type-safe MDX, and the build pipeline that powers this portfolio.

  • #architecture
  • #next.js
  • #contentlayer
  • #typescript
  • #mdx
Architecture Overview โ€” How This Site Is Built

This post walks through the technical architecture of this portfolio โ€” the frameworks, the content pipeline, and the build process that generates fully-typed, statically-rendered pages.

High-Level Overview

The content pipeline flows like this:

  1. MDX Files โ†’ Contentlayer parses โ†’ Type Definitions + JSON Data
  2. Types + Data โ†’ Next.js consumes โ†’ React Pages
  3. Pages โ†’ Build process โ†’ Static HTML
  4. Static HTML โ†’ Deploy โ†’ Vercel Edge Network

Key principles:

  1. Content as data โ€” MDX files are parsed into typed JSON at build time
  2. Type-safety everywhere โ€” TypeScript strict mode, no any types
  3. Static by default โ€” Every route pre-rendered, zero server runtime
  4. Progressive enhancement โ€” HTML first, JavaScript for interactivity

Tech Stack Breakdown

Next.js 15 (App Router)

Using the latest App Router for:

// app/(site)/layout.tsx
export default function SiteLayout({ children }) {
  return (
    <div className="flex min-h-screen">
      <Sidebar />
      <main className="flex-1">{children}</main>
      <RightRail />
    </div>
  );
}
 
// app/(site)/work/page.tsx
import { allProjects } from '@/.contentlayer/generated';
 
export default function WorkIndex() {
  const projects = allProjects
    .filter(p => p.status === 'shipped')
    .sort((a, b) => b.date - a.date);
 
  return <ProjectGrid projects={projects} />;
}

Why App Router over Pages?

  • Better layouts with nested routes
  • Server Components by default (less JS)
  • Streaming SSR ready (future-proofing)
  • Improved data fetching patterns

Contentlayer โ€” Type-Safe MDX

The magic behind type-safe content:

// contentlayer.config.ts
import { defineDocumentType, makeSource } from 'contentlayer/source-files';
import readingTime from 'reading-time';
 
export const Blog = defineDocumentType(() => ({
  name: 'Blog',
  filePathPattern: `blog/**/*.mdx`,
  contentType: 'mdx',
  fields: {
    title: { type: 'string', required: true },
    date: { type: 'date', required: true },
    tags: { type: 'list', of: { type: 'string' } },
    excerpt: { type: 'string' },
    author: { type: 'string' },
    cover: { type: 'string' },
    series: { type: 'string' },
    seo: { type: 'json' },
  },
  computedFields: {
    slug: {
      type: 'string',
      resolve: (doc) => doc._raw.flattenedPath.replace(/^blog\//, ''),
    },
    url: {
      type: 'string',
      resolve: (doc) => `/blog/${doc._raw.flattenedPath.replace(/^blog\//, '')}`,
    },
    readingTime: {
      type: 'json',
      resolve: (doc) => readingTime(doc.body.raw),
    },
  },
}));

What Contentlayer does:

  1. Parses MDX โ€” Reads all .mdx files in content/
  2. Validates frontmatter โ€” Required fields must exist, types must match
  3. Generates TypeScript โ€” Creates .contentlayer/generated/types.d.ts
  4. Outputs JSON โ€” Each file becomes structured data
  5. Computes fields โ€” Derived values like slug, reading time

The result:

import { allBlogs } from '@/.contentlayer/generated';
 
// โœ… Fully typed!
allBlogs[0].title;       // string
allBlogs[0].date;        // string (IsoDateTimeString)
allBlogs[0].tags;        // string[] | undefined
allBlogs[0].readingTime; // { text: string, minutes: number, ... }
allBlogs[0].slug;        // string (computed)

Build-time validation:

$ npm run build
 
Error: Found 1 problem in 3 documents.
 
โ””โ”€โ”€ "blog/broken-post.mdx" of type "Blog" has incompatible fields:
    โ€ข title: Required field missing
    โ€ข date: Expected date, got string "yesterday"

Build fails, not production. ๐ŸŽ‰

MDX Configuration

Rich content with React components:

// contentlayer.config.ts
export default makeSource({
  contentDirPath: 'content',
  documentTypes: [Blog, Project],
  mdx: {
    remarkPlugins: [
      remarkGfm,              // GitHub Flavored Markdown (tables, task lists)
    ],
    rehypePlugins: [
      rehypeSlug,             // Add IDs to headings
      [rehypeAutolinkHeadings, { behavior: 'wrap' }],  // Link headings
      [rehypePrettyCode, { theme: 'github-dark' }],    // Syntax highlighting
    ],
  },
});

Features enabled:

  • Tables โ€” GitHub-style markdown tables
  • Code blocks โ€” Syntax highlighting with line numbers
  • Heading anchors โ€” Clickable heading links
  • Custom components โ€” Import React components in MDX

Example MDX features:

  • Regular markdown formatting (bold, italic, lists)
  • Custom React components embedded in content
  • Syntax-highlighted code blocks
  • GitHub Flavored Markdown extensions

Tailwind CSS 4

Utility-first styling with design tokens:

/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
 
@layer base {
  :root {
    --bg: 255 255 255;
    --fg: 0 0 0;
    --border: 229 231 235;
    --accent: 59 130 246;
  }
 
  .dark {
    --bg: 0 0 0;
    --fg: 255 255 255;
    --border: 38 38 38;
    --accent: 96 165 250;
  }
}

Usage in components:

<div className="bg-[--bg] text-[--fg] border-[--border]">
  <h1 className="text-4xl font-semibold">Hello</h1>
  <p className="text-[--fg]/80 mt-2">Supporting text</p>
</div>

Why Tailwind over CSS-in-JS?

  • Zero runtime overhead
  • Better tree-shaking (unused classes removed)
  • Fast development with autocomplete
  • Easy theming with CSS variables

Project Structure

/calvin-dev
โ”œโ”€โ”€ app/
โ”‚   โ”œโ”€โ”€ (site)/
โ”‚   โ”‚   โ”œโ”€โ”€ layout.tsx          # Shared layout (Sidebar, RightRail)
โ”‚   โ”‚   โ”œโ”€โ”€ page.tsx             # Home
โ”‚   โ”‚   โ”œโ”€โ”€ work/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ page.tsx         # Work index
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ [slug]/page.tsx  # Project detail
โ”‚   โ”‚   โ”œโ”€โ”€ blog/
โ”‚   โ”‚   โ”‚   โ”œโ”€โ”€ page.tsx         # Blog index
โ”‚   โ”‚   โ”‚   โ””โ”€โ”€ [slug]/page.tsx  # Blog post
โ”‚   โ”‚   โ””โ”€โ”€ about/page.tsx       # About page
โ”‚   โ”œโ”€โ”€ api/og/route.tsx         # Dynamic OG images
โ”‚   โ””โ”€โ”€ globals.css              # Tailwind + theme variables
โ”‚
โ”œโ”€โ”€ content/
โ”‚   โ”œโ”€โ”€ blog/*.mdx               # Blog posts
โ”‚   โ””โ”€โ”€ work/*.mdx               # Project case studies
โ”‚
โ”œโ”€โ”€ components/
โ”‚   โ”œโ”€โ”€ layout/                  # Sidebar, RightRail
โ”‚   โ”œโ”€โ”€ cards/                   # ProjectCard, BlogCard
โ”‚   โ””โ”€โ”€ ui/                      # Button, Dialog primitives
โ”‚
โ”œโ”€โ”€ types/
โ”‚   โ””โ”€โ”€ content.ts               # Extended types (Project, BlogPost)
โ”‚
โ”œโ”€โ”€ .contentlayer/
โ”‚   โ””โ”€โ”€ generated/               # Auto-generated types & JSON
โ”‚
โ”œโ”€โ”€ contentlayer.config.ts       # Content schema
โ””โ”€โ”€ next.config.ts               # Next.js config

Build Pipeline

1. Content Generation

$ npm run build
 
> contentlayer build
 
Generated 12 documents in .contentlayer/
  โ€ข 10 Blog posts
  โ€ข 2 Projects

Contentlayer runs before Next.js build, generating:

  • .contentlayer/generated/types.d.ts โ€” TypeScript definitions
  • .contentlayer/generated/Blog/*.json โ€” Post data
  • .contentlayer/generated/Project/*.json โ€” Project data

2. Next.js Build

> next build
 
Route (app)                              Size     First Load JS
โ”Œ โ—‹ /                                 5.44 kB         107 kB
โ”œ โ—‹ /work                             3.21 kB         105 kB
โ”œ โ—‹ /work/memfuse-v1                  8.92 kB         112 kB
โ”œ โ—‹ /blog                             3.45 kB         105 kB
โ”œ โ—‹ /blog/launch-notes                7.81 kB         111 kB
โ”” โ—‹ /about                            2.34 kB         104 kB
 
โ—‹  (Static)  prerendered as static content

All routes are static HTML. No server runtime needed.

3. Deployment

$ git push origin main
 
โ†’ Vercel detects push
โ†’ Runs build
โ†’ Deploys to Edge network
โ†’ Lighthouse CI checks performance

Preview deployments: Every PR gets a unique preview URL. Lighthouse scores posted as comment.

Performance Optimizations

Image Optimization

import Image from 'next/image';
import coverImage from '@/public/images/work/memfuse/cover.png';
 
<Image
  src={coverImage}
  alt="MemFuse architecture diagram"
  placeholder="blur"
  className="rounded-lg"
/>

Next.js automatically:

  • Generates WebP/AVIF versions
  • Creates responsive srcsets
  • Extracts blur placeholder
  • Lazy loads below fold

Font Optimization

// app/layout.tsx
import { Inter } from 'next/font/google';
 
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});
 
export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Next.js:

  • Self-hosts fonts (no external requests)
  • Subsets to only used glyphs
  • Preloads font files
  • Zero layout shift

Code Splitting

Every page gets its own bundle. Shared code extracted to chunks:

chunks/255-4efeec91c7871d79.js    45.7 kB  (React core)
chunks/4bd1b696-c023c6e3521b1417.js 54.2 kB  (React DOM)
chunks/app-pages-internals.js      1.9 kB  (Next.js internals)

Result: Landing page loads only what it needs.

Type Safety in Practice

Before Contentlayer

// Fragile, runtime errors
const posts = fs.readdirSync('content/blog')
  .map(file => {
    const content = fs.readFileSync(file);
    const parsed = matter(content);
    return {
      title: parsed.data.title,      // โŒ Could be undefined
      date: new Date(parsed.data.date), // โŒ Could be invalid
      slug: file.replace('.mdx', ''), // โŒ Manual logic
    };
  });

After Contentlayer

import { allBlogs } from '@/.contentlayer/generated';
 
// โœ… Type-safe, build-time validated
const posts = allBlogs.map(post => ({
  title: post.title,       // โœ… Guaranteed string
  date: post.date,         // โœ… Valid ISO date
  slug: post.slug,         // โœ… Computed correctly
  readingTime: post.readingTime.text, // โœ… Auto-calculated
}));

Errors caught at build time, not runtime.

Lessons Learned

1. Contentlayer Is a Game-Changer

Type-safe content transforms how you build content sites. Refactoring is safe, templates are simpler, and bugs vanish.

2. App Router Pays Off Long-Term

Learning curve is steeper than Pages Router, but layouts, loading states, and Server Components are worth it.

3. Static Generation Scales

12 pages? 1200 pages? Doesn't matter. Build time scales linearly, runtime performance stays perfect.

4. CSS Variables > Hardcoded Colors

Theming becomes trivial when colors are variables. Dark mode is a CSS class toggle.

Resources

Source Code

Full source available: github.com/calvinku/calvin-dev

Check out:

  • contentlayer.config.ts โ€” Schema definitions
  • app/(site)/ โ€” Page structure
  • types/content.ts โ€” Extended type helpers
  • .github/workflows/ โ€” CI/CD pipeline

Questions? Find me on Twitter or email.