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:
- MDX Files โ Contentlayer parses โ Type Definitions + JSON Data
- Types + Data โ Next.js consumes โ React Pages
- Pages โ Build process โ Static HTML
- Static HTML โ Deploy โ Vercel Edge Network
Key principles:
- Content as data โ MDX files are parsed into typed JSON at build time
- Type-safety everywhere โ TypeScript strict mode, no
any
types - Static by default โ Every route pre-rendered, zero server runtime
- 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:
- Parses MDX โ Reads all
.mdx
files incontent/
- Validates frontmatter โ Required fields must exist, types must match
- Generates TypeScript โ Creates
.contentlayer/generated/types.d.ts
- Outputs JSON โ Each file becomes structured data
- 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 definitionsapp/(site)/
โ Page structuretypes/content.ts
โ Extended type helpers.github/workflows/
โ CI/CD pipeline