Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button
Welcome to the first post in my "Built My Personal Website with Next.js" series! Over the next few posts, I'll be breaking down exactly how I built this site from scratch — every config file, every component, every design decision.
In this post, I'll walk you through Phase 0 — the scaffolding stage. This is the unsexy but essential work that makes everything else possible: initializing Next.js, configuring Tailwind CSS, setting up a centralized site config, and wiring up Contentlayer so I can write posts in MDX.
If you're thinking about building your own personal site with Next.js, this is a good place to start. I'll explain not just what I did, but why I made each decision. Some of these choices seem small at the time but end up shaping the entire project. Let's dive in.
Project Init + Prettier + .gitignore
The very first decision I had to make was choosing between Next.js 13's App Router and Pages Router. If you've spent any time in the Next.js ecosystem recently, you know the App Router is the shiny new thing. Every tutorial and blog post seems to push you toward it. But I deliberately went with the Pages Router, and here's why:
- It's more mature and battle-tested — the Pages Router has been stable for years
- The documentation and community examples are far richer — when you hit a problem, there are answers
- delba.dev — the site that inspired much of this project — uses the Pages Router
- I didn't need React Server Components for a personal blog — static generation is more than enough
- The mental model is simpler: each file in
pages/is a route, period
This was surprisingly tricky to decide, because the internet is full of "App Router is the future" takes. And they're not wrong — the App Router is the future of Next.js. But "future" and "best choice for a personal blog in 2026" are different things. For a content-focused site, Pages Router is rock solid and I have zero regrets.
Installing Dependencies
Here are the core dependencies I installed right after yarn init:
yarn add next@13 react@18.2 react-dom@18.2 typescript@4
yarn add -D tailwindcss postcss autoprefixer prettier prettier-plugin-tailwindcssLet me break down the choices:
- next@13: The latest stable version at the time with great Pages Router support
- react@18.2: Pinned to a specific version — I don't want surprise breaking changes
- typescript@4: I kept TS at version 4 because Next.js 13 had some compatibility quirks with TS 5 at the time. You might be fine with a newer version, but I didn't want to fight that battle during scaffolding
- prettier-plugin-tailwindcss: This plugin automatically sorts Tailwind classes in a consistent order — it's a small thing that makes a huge difference when you're reading JSX
Setting Up Path Aliases
One of the first things I configure in any TypeScript project is path aliases. Without them, you end up with import hell:
// Without aliases — this is painful:
import { Button } from "../../../../ui/Button"
import { formatDate } from "../../../lib/formatDate"
// With aliases — much cleaner:
import { Button } from "@/ui/Button"
import { formatDate } from "@/lib/formatDate"Here's my tsconfig.json:
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"paths": {
"@/ui/*": ["ui/*"],
"@/lib/*": ["lib/*"],
"@/site.config": ["./site.config"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
}
}A few things worth explaining:
"strict": true— Always. TypeScript without strict mode is just JavaScript with extra steps."@/ui/*"and"@/lib/*"— These map to myui/andlib/directories at the project root. I deliberately put these outside thepages/directory because they're not routes."contentlayer/generated"— This is important. Contentlayer generates TypeScript types in.contentlayer/generated, and this alias lets you import them asimport { allPosts } from "contentlayer/generated"instead of digging into the.contentlayerdirectory.
Configuring Prettier
For code formatting, I set up Prettier with the Tailwind plugin:
{
"semi": false,
"trailingComma": "all",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss"]
}Let me explain each setting:
"semi": false— No semicolons. This is a personal preference, but I find the code cleaner without them. The important thing is to be consistent."trailingComma": "all"— Trailing commas everywhere. It makes git diffs cleaner because adding a new line doesn't modify the previous line."printWidth": 100— I prefer 100 characters over the default 80. It gives me more room before Prettier wraps lines, which I find more readable on modern monitors."plugins": ["prettier-plugin-tailwindcss"]— This is the star. It automatically sorts Tailwind classes in a consistent, opinionated order. SoclassName="p-4 bg-red-500 flex"becomesclassName="flex bg-red-500 p-4". It sounds minor, but when you're scanning JSX, having classes in a predictable order makes a real difference.
The .gitignore
I won't show the full .gitignore (it's mostly the standard Next.js template), but there are a few entries worth highlighting:
# dependencies
/node_modules
# build output
/.next/
/out/
# Contentlayer generated files
/.contentlayer/
# environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea/
*.tsbuildinfoThe .contentlayer/ entry is important — that directory is generated at build time and should never be committed. Same with .env.local — your actual environment variables should stay local.
Site Config + .env
One thing I knew from the start: I wanted a single source of truth for all my personal data. Your name, your domain, your social links, your navigation items, your feature flags — all of it in one file. I didn't want to scatter this information across dozens of components and then have to update five places when I changed my Twitter handle.
The solution was a simple site.config.ts file at the root of the project. Any component that needs my name, a social link, or a nav item just imports directly:
import { siteConfig } from "@/site.config"The Config Structure
Here's the full structure (abbreviated for this post — the real file has more entries):
export const siteConfig = {
name: "Your Name",
domain: "yourdomain.com",
description: "Welcome to my digital garden...",
meta: "Your role or tagline",
social: {
twitter: { handle: "@yourhandle", url: "https://twitter.com/yourhandle" },
youtube: { url: "https://youtube.com/yourchannel" },
},
nav: [
{ label: "Videos", href: "/videos", icon: "video" },
{ label: "Posts", href: "/posts", icon: "post" },
],
features: {
video: false,
tweets: false,
},
}Let me walk through each section:
nameanddomain— Used in meta tags, the footer, and anywhere I need to reference the site owner.description— The default meta description for the site. Individual pages can override this.meta— A short tagline like "Developer and writer" that appears in the header and Open Graph cards.social— Each social platform gets an object with aurland optionally ahandle. The handle is used in Twitter Card meta tags.nav— The navigation items for the site. Each one has alabel,href, andicon(which maps to a Lucide icon name).features— Feature flags! This is surprisingly handy. Thevideoflag controls whether the Videos section appears in the nav and whether video-related components render. When I eventually add video support, I just flipvideo: trueand everything lights up.
Why This Pattern Works
The beauty of this approach is that components are decoupled from your personal data. If you want to fork this project for your own site, you just edit site.config.ts and you're done. No hunting through components to find hardcoded strings.
It also means you can use the config in non-component contexts:
// In next-sitemap.config.js
const { siteConfig } = require("./site.config")
module.exports = { siteUrl: `https://${siteConfig.domain}` }
// In next-seo config
const defaultSeo = {
title: siteConfig.name,
description: siteConfig.description,
openGraph: { url: `https://${siteConfig.domain}` },
}Environment Variables
I also created a .env.example file to document the environment variables the project needs:
# Database (for any future API routes)
DATABASE_URL=
# YouTube API (for fetching video data)
YOUTUBE_DATA_API_KEY=
# Twitter API (for embedding tweets in posts)
TWITTER_BEARER_TOKEN=Even if some of these aren't used yet, having them documented saves future-me from guessing what's needed. The .env.example file is committed to git (with empty values), while the actual .env.local is gitignored.
A few other variables I added later:
# For the contact form (if I add one)
RESEND_API_KEY=
# For analytics
NEXT_PUBLIC_GA_ID=Tailwind Config
The Tailwind config is where things start to get interesting. This is where the visual identity of the site begins to take shape, even though we're just writing configuration.
The Gray-to-Stone Remap
I made a deliberate decision to remap the entire gray scale to stone. Here's why: the default Tailwind grays (gray-50 through gray-900) have a cool blue undertone. They look great on technical documentation sites, but they feel sterile on a personal site. Stone (stone-50 through stone-900) has a warm, earthy feel that fits the vibe I was going for.
In practice, this means every time I write bg-gray-100 or text-gray-600, Tailwind uses stone colors instead. The remap looks like this:
// In tailwind.config.js
theme: {
extend: {
colors: {
gray: colors.stone,
},
},
},It's a one-line change that affects the entire color palette. Brilliant.
Custom Keyframes
The config also includes custom keyframes for two animations I knew I'd need from studying delba.dev:
keyframes: {
shimmer: {
"100%": { transform: "translateX(100%)" },
},
mutation: {
"0%": { background: "hsl(var(--accent) / 3%)" },
"10%": { background: "hsl(var(--accent) / 15%)" },
"100%": { background: "hsl(var(--accent) / 0%)" },
},
},
animation: {
shimmer: "shimmer 1.5s infinite",
mutation: "mutation 0.3s ease-in-out",
},Let me explain what each one does:
- shimmer: A loading skeleton animation that slides a highlight across a placeholder element. It's a subtle "this content is loading" indicator that you see on YouTube, Facebook, and other modern sites. I use it for the post card skeletons while content is fetching.
- mutation: A flash effect for highlighting newly changed content. I use this in the Table of Contents when the active heading changes — it briefly glows the accent color then fades out. It's a tiny detail, but it makes the ToC feel alive and responsive.
The mutation animation uses CSS variables (var(--accent)) so it automatically adapts to the current theme. In light mode it flashes terracotta; in dark mode it flashes rose.
Custom Box Shadows
I also defined custom box shadows for layered surfaces:
boxShadow: {
"surface-glass": "0 1px 3px rgb(0 0 0 / 5%), 0 0 0 1px rgb(0 0 0 / 5%)",
"surface-elevation-low": "0 2px 8px rgb(0 0 0 / 8%), 0 0 0 1px rgb(0 0 0 / 4%)",
},These are designed for two specific use cases:
surface-glass: Used on the navigation bar. It gives a frosted glass effect — a very subtle shadow plus a 1px border. It's almost invisible, but it creates just enough separation to make the nav bar feel elevated above the content.surface-elevation-low: Used for cards and elevated content areas. Slightly more shadow than the glass effect, but still restrained. I didn't want heavy drop shadows — they look dated.
Both use very low opacity values (5% and 8%) so they work well in both light and dark themes. Heavy shadows look fine on light backgrounds but terrible on dark ones. Subtle shadows work everywhere.
Contentlayer Config + Post Definition
This is where the magic happens. Contentlayer is a library that turns your MDX files into typed, importable data. It's the bridge between your content files and your React components.
What Problem Does Contentlayer Solve?
Without Contentlayer, building a blog in Next.js means:
- Writing MDX files in a
content/directory - Using
fsto read those files at build time - Using
gray-matterto parse the frontmatter - Using
next-mdx-remoteor a custom MDX compiler to render the content - Manually defining TypeScript types for your frontmatter
- Updating those types every time you add a new field
That's a lot of boilerplate. Contentlayer eliminates steps 2-6. You define a schema, and Contentlayer gives you fully typed objects you can use in your components.
The Workflow
With Contentlayer, the workflow is:
Step 1: Write your post in MDX:
---
title: "My First Post"
publishedAt: "2026-05-25"
description: "A short description of the post."
status: "published"
---
Hello world! This is **bold** text.
## A Heading
Some more content here.Step 2: Import the post in your page:
import { allPosts } from "contentlayer/generated"
export async function getStaticProps() {
const post = allPosts.find((p) => p.slug === "my-first-post")
return { props: { post } }
}That's it. No fs.readFile(), no gray-matter(), no manual type definitions. Contentlayer handles all of it, and the types are generated automatically.
The Post Definition
The Post definition (Post.ts) defines the schema for blog posts. Think of it as the "model" for your content:
import { defineDocumentType } from "contentlayer/source-files"
export const Post = defineDocumentType(() => ({
name: "Post",
filePathPattern: "posts/**/*.mdx",
contentType: "mdx",
fields: {
title: { type: "string", required: true },
publishedAt: { type: "string", required: true },
description: { type: "string", required: true },
status: { type: "string", required: true },
},
computedFields: {
slug: {
type: "string",
resolve: (post) => post._raw.flattenedPath.replace("posts/", ""),
},
headings: {
type: "json",
resolve: async (post) => {
const headingRegex = /(?<flag>#{2,3})\s+(?<content>.+)/g
return Array.from(post.rawBody.matchAll(headingRegex)).map((match) => ({
level: match.groups?.flag?.length === 2 ? 2 : 3,
text: match.groups?.content ?? "",
}))
},
},
tweetIds: {
type: "json",
resolve: (post) => {
const tweetMatches = post.rawBody.match(/<Tweet\sid="([0-9]+)"\s\/>/g)
return tweetMatches?.map((tweet) => tweet.match(/[0-9]+/)?.[0]) ?? []
},
},
publishedAtFormatted: {
type: "string",
resolve: (post) => formatShortDate(post.publishedAt),
},
},
}))Let me walk through the important parts:
filePathPattern: "posts/**/\*.mdx"— Contentlayer looks for MDX files matching this glob in thecontent/directory (defined in the main config). The\*\*means it supports nested directories, so you could organize posts by year if you wanted.contentType: "mdx"— This tells Contentlayer to compile the content as MDX (not plain Markdown), so you can use React components inline.fields— These are the frontmatter fields.required: truemeans Contentlayer will error at build time if a post is missing that field. This catches mistakes early.computedFields— These are derived automatically from the raw content. You never set them in frontmatter; they're computed at build time.
The computed fields deserve a closer look:
slug: Extracted from the file path. A file atcontent/posts/my-post.mdxgets slug"my-post".headings: A regex extracts all##and###headings from the raw markdown. This powers the Table of Contents component.tweetIds: Scans the content for<Tweet id="123456" />components and extracts their IDs. This lets me fetch tweet data at build time and embed real tweets.publishedAtFormatted: A human-readable date like "May 25, 2026" computed from thepublishedAtstring.
The Import Constraint
Here's a gotcha that bit me hard: Contentlayer definitions MUST use relative imports. This is due to an esbuild limitation in how Contentlayer processes files.
// This works:
import { formatShortDate } from "../../lib/formatShortDate"
// This breaks:
import { formatShortDate } from "@/lib/formatShortDate"I wasted a solid hour debugging why my computed fields weren't working before discovering this. The @/ path alias doesn't resolve inside Contentlayer's build pipeline, so you're stuck with relative paths in these files. It's ugly, but it is what it is.
Pro tip: Keep Contentlayer definitions simple. The more you import into them, the more relative paths you'll need. I moved all the heavy lifting (date formatting, slug generation) into small, self-contained utility functions specifically to minimize the number of imports.
The Contentlayer Config
The Contentlayer config (contentlayer.config.ts) ties it all together and adds the MDX plugin pipeline:
import { makeSource } from "contentlayer/source-files"
import rehypePrismPlus from "rehype-prism-plus"
import rehypeSlug from "rehype-slug"
import remarkGfm from "remark-gfm"
import { Post } from "./content/Post"
export default makeSource({
contentDirPath: "content",
documentTypes: [Post],
mdx: {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeSlug, [rehypePrismPlus, { ignoreMissing: true }]],
},
})The plugin stack is straightforward, but each plugin earns its place:
- remark-gfm: Adds GitHub Flavored Markdown support — tables, strikethrough (
~~text~~), task lists (- [x] done), and autolinked URLs. If you've ever written a GitHub README, you know what to expect. - rehype-slug: Automatically adds
idattributes to headings. So## My Headingbecomes<h2 id="my-heading">My Heading</h2>. This is what powers the Table of Contents — I can link directly to#my-heading. - rehype-prism-plus: Syntax highlighting for code blocks using Prism. The
ignoreMissing: trueoption prevents errors when the language isn't registered — it just skips highlighting instead of crashing.
The Content Directory Structure
With Contentlayer configured, here's how the directory structure looks:
Each .mdx file in content/posts/ becomes a typed Post object that I can import and use anywhere in the app. No manual file reading, no parsing, no type definitions. It just works.
Fonts + Globals + App Shell
The final piece of scaffolding is the visual foundation: fonts, CSS variables, and the app shell. This is where the site starts to feel like yours instead of a generic Next.js template.
Font Strategy
I chose three fonts for the site, each with a specific role:
import { PT_Serif } from "@next/font/google"
import { Ubuntu } from "@next/font/google"
import { Noto_Serif_SC } from "@next/font/google"
const ptSerif = PT_Serif({
variable: "--font-pt-serif",
subsets: ["latin"],
weight: ["400", "700"],
})
const ubuntu = Ubuntu({
variable: "--font-ubuntu",
subsets: ["latin"],
weight: ["400", "500", "700"],
})
const notoSerifSC = Noto_Serif_SC({
variable: "--font-noto-serif-sc",
subsets: ["latin"],
weight: ["400", "700"],
})Here's the thinking behind each choice:
- PT Serif (body text): A readable serif font that gives the prose a warm, editorial feel. Serif fonts have a long history in print publishing, and they work beautifully for long-form reading. PT Serif is well-hinted and renders cleanly at small sizes.
- Ubuntu (headings): A clean sans-serif that provides contrast against the body text. Ubuntu has a slightly rounded, friendly feel that matches the personal tone of the site. I use it at
500and700weights for headings. - Noto Serif SC (Chinese text): For any Chinese text, with proper glyph support. If I write a post in Chinese (or include Chinese terms), this font ensures the characters look beautiful.
Using Next.js's @next/font means the fonts are automatically optimized — they're self-hosted at build time with zero layout shift. No external requests to Google Fonts, no FOUT (Flash of Unstyled Text), no CLS (Cumulative Layout Shift). The fonts are baked right into the build output.
Theme Variables
The globals.css file defines the color palette using HSL CSS variables. This is the key to making light/dark mode switching seamless:
:root {
--background: 38 33% 94%; /* cream */
--foreground: 20 10% 12%; /* warm near-black */
--accent: 20 48% 53%; /* terracotta */
--muted: 20 10% 50%; /* muted text */
--border: 20 10% 85%; /* subtle borders */
}
.dark {
--background: 356 10% 10%; /* deep charcoal */
--foreground: 356 0% 95%; /* off-white */
--accent: 356 89% 60%; /* rose */
--muted: 356 0% 55%; /* muted text */
--border: 356 10% 20%; /* subtle borders */
}I love this approach because every component just references the variables:
.card {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
}
.accent-text {
color: hsl(var(--accent));
}
.muted {
color: hsl(var(--muted));
}When the theme switches, every component updates automatically. No conditional classes, no theme-specific CSS files, no prop drilling. It's just CSS doing what CSS does best.
The light theme is a warm cream and terracotta palette. The dark theme shifts to a deep charcoal with rose accents. The contrast between the two feels intentional rather than just "inverting the colors." I spent a lot of time picking these specific HSL values — the warmth of the light theme and the coolness of the dark theme create distinct moods.
Typography Styles
Beyond the color variables, globals.css also sets up the typography. Here's a snippet:
body {
font-family: var(--font-pt-serif), Georgia, serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-ubuntu), system-ui, sans-serif;
font-weight: 700;
}
.prose {
font-size: 1.125rem;
line-height: 1.75;
}
.prose h2 {
margin-top: 2.5rem;
margin-bottom: 1rem;
font-size: 1.5rem;
}
.prose p {
margin-bottom: 1.25rem;
}The prose class is used on blog post content. I'm not using Tailwind Typography (@tailwindcss/typography) because I wanted finer control over the spacing and sizing. The defaults from Typography are great for prototyping, but for a polished site, I preferred hand-tuning the values.
The App Shell
Finally, _app.tsx ties everything together. This is the root component that wraps every page:
import { DefaultSeo } from "next-seo"
import { ThemeProvider } from "next-themes"
import { LangProvider } from "@/lib/lang"
import "@/styles/globals.css"
export default function App({ Component, pageProps }) {
return (
<>
<style jsx global>{`
:root {
--font-pt-serif: ${ptSerif.variable};
--font-ubuntu: ${ubuntu.variable};
--font-noto-serif-sc: ${notoSerifSC.variable};
}
`}</style>
<DefaultSeo {...defaultSeo} />
<ThemeProvider attribute="class">
<LangProvider>
<Component {...pageProps} />
</LangProvider>
</ThemeProvider>
</>
)
}Let me break down what's happening:
- Font CSS variables are injected via a global
<style jsx global>tag. This is the Next.js way of adding global styles in_app.tsx. The font variables are then available everywhere — in the Tailwind config, in inline styles, in CSS modules. - DefaultSeo from next-seo sets the baseline meta tags for every page. This includes the title template (
%s | Your Name), the default description, and Open Graph images. Individual pages can override any of these. - ThemeProvider from next-themes handles the light/dark toggle. The
attribute="class"setting means it works by adding/removing a.darkclass on the<html>element, which is what Tailwind's dark mode uses. - LangProvider is a custom context I use for language switching (English/Chinese). It's not essential for everyone, but it's part of my setup since I write in both languages.
The Default SEO Config
Here's what the defaultSeo object looks like:
import { siteConfig } from "@/site.config"
const defaultSeo = {
titleTemplate: `%s | ${siteConfig.name}`,
defaultTitle: siteConfig.name,
description: siteConfig.description,
openGraph: {
type: "website",
locale: "en_US",
url: `https://${siteConfig.domain}`,
siteName: siteConfig.name,
},
twitter: {
handle: siteConfig.social.twitter.handle,
cardType: "summary_large_image",
},
}Notice how it pulls everything from site.config — no hardcoded strings. If I change my name or domain, it updates everywhere.
Wrapping Up
And that's Phase 0! At this point, running yarn dev gives you a blank page — but the foundation is solid. Let's recap what we've set up:
- A Next.js 13 project with the Pages Router and TypeScript
- Tailwind CSS with custom colors (stone remap), animations (shimmer + mutation), and shadows (glass + elevation)
- A centralized site config that every component can import — single source of truth for all personal data
- Contentlayer wired up to turn MDX files into typed, importable data with computed fields
- Three fonts loading with zero layout shift via
@next/font - A theme system with warm light (cream + terracotta) and dark (charcoal + rose) palettes
- The app shell with SEO defaults and theme switching
None of this is visible to a visitor yet, but all the plumbing is in place. The boring scaffolding work pays off massively when we start building actual features — every component just works because the foundation is well thought out.
In Phase 1, I'll build the layout shell — the navigation bar with a noise overlay, the gradient background, and the basic page structure. That's where things start to actually look like a website. I'll also cover how to set up the noise texture CSS trick and the subtle gradient that gives the site its distinctive background.
Thanks for reading! If you're building your own site, I hope this gives you a useful starting point. The key takeaway is: invest in the foundation. It's tempting to jump straight to building UI components, but getting the config, theming, and content pipeline right first saves you from painful refactors later. Trust me — future-you will thank present-you for spending the time to get this right.
- Setting Up the Foundation: Next.js, Tailwind, and Contentlayer
- Building the Layout Shell: Navigation, Footer, and Shared Components
- Creating the Blog: MDX Content, Posts, and Category Filtering
- Polishing the Experience: Animations, Loading States, and Accessibility
- Adding Video Support: YouTube Integration and Feature Flags
- Integrating Tweets: Twitter API, Seed Data, and the Unicode Page
- Theming the Site: CSS Variables, Theme Presets, and a Settings Page
- Going Multilingual: English and Chinese Content with Language Switching
- The Backend: Prisma, API Routes, Metrics, and the Like Button