Creating the Blog: MDX Content, Posts, and Category Filtering

Jun 1
·

Phase 2 is where the site actually becomes a blog. Everything before this was scaffolding — theme, layout, navigation. Now we're building the thing people actually come for: content.

This phase has eight tasks. They range from defining the content schema to building a single catch-all route that handles six different URL patterns. It's the longest post in this series, because there's a lot of moving parts that need to fit together.

Let's get into it.

Content + Post Definition + Helpers

Contentlayer transforms your .mdx files into structured JSON. The Post document type is where everything starts — it defines the schema for every blog post on the site.

content/definitions/Post.ts
import { defineDocumentType } from "contentlayer/source-files"
import GithubSlugger from "github-slugger"
import { formatShortDate } from "../../lib/formatShortDate"
import { Series } from "./Series"
import { Category } from "./Category"
 
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" },
    status: { type: "enum", options: ["draft", "published"], required: true },
    categories: {
      type: "list",
      of: Category,
    },
    series: {
      type: "nested",
      of: Series,
    },
  },
  computedFields: {
    headings: {
      type: "json",
      resolve: async (doc) => {
        const slugger = new GithubSlugger()
        const regXHeader = /\n\n(?<flag>#{1,6})\s+(?<content>.+)/g
 
        const headings = Array.from(doc.body.raw.matchAll(regXHeader)).map((match) => {
          const groups = (match as RegExpMatchArray).groups as
            | { flag?: string; content?: string }
            | undefined
          const flag = groups?.flag
          const content = groups?.content
          return {
            heading: flag?.length,
            text: content,
            slug: content ? slugger.slug(content) : undefined,
          }
        })
 
        return headings
      },
    },
    tweetIds: {
      type: "json",
      resolve: (doc) => {
        const tweetMatches = doc.body.raw.match(/<StaticTweet\sid="[0-9]+"[\s\S]*?\/>/g)
        const tweetIDs = tweetMatches?.map((tweet: any) => tweet.match(/[0-9]+/g)[0])
        return tweetIDs ?? []
      },
    },
    publishedAtFormatted: {
      type: "string",
      resolve: (doc) => {
        return formatShortDate(doc.publishedAt)
      },
    },
    slug: {
      type: "string",
      resolve: (doc) =>
        doc._raw.sourceFileName.replace(/\.(en|zh)\.mdx$/, "").replace(/\.mdx$/, ""),
    },
    lang: {
      type: "string",
      resolve: (doc) => {
        const match = doc._raw.sourceFileName.match(/\.(en|zh)\.mdx$/)
        return match ? match[1] : "en"
      },
    },
  },
}))

There's a lot going on here, so let me break it down.

Fields are what you write in the frontmatter of each .mdx file — title, publishedAt, description, status, categories, and series. Contentlayer validates these at build time. If a required field is missing, the build fails with a helpful error message pointing to the exact file.

Computed fields are derived from the document itself. You don't write these in your frontmatter — Contentlayer calculates them automatically:

  • slug — Strips the .mdx extension (and the language suffix like .en) from the filename. hello-world.en.mdx becomes hello-world.
  • lang — Extracts the language code from the filename. hello-world.en.mdx gives "en", hello-world.zh.mdx gives "zh".
  • headings — Parses the raw MDX source with a regex to extract all headings (h1-h6). Uses GithubSlugger to generate URL-safe slugs that match what rehype-slug produces. This is critical — the TOC and the rendered headings must use the same slugs or clicking a TOC link won't scroll to the right place.
  • tweetIds — Extracts Twitter/X embed IDs from <StaticTweet> components in the post. Used to prefetch tweet data at build time.
  • publishedAtFormatted — Formats the raw date string into something human-readable like "Jun 1, 2026".

Content Helpers

Once Contentlayer generates the data, you need helper functions to shape it for different contexts. A listing page doesn't need the full post body. A blog detail page doesn't need the raw MDX source.

lib/contentlayer.ts
import { pick } from "contentlayer/client"
import { Post, Video } from "contentlayer/generated"
 
export const allCategoryNames = ["Next.js", "MDX", "Next Conf", "React Conf"]
export const allCategorySlugs = ["next", "mdx", "next-conf", "react-conf"]
 
// Format post data for listing pages — picks only what's needed for a card preview
export const formatPostPreview = (post: Post) => {
  const partialPost = pick(post, [
    "categories",
    "slug",
    "title",
    "description",
    "publishedAt",
    "publishedAtFormatted",
  ])
 
  return {
    ...partialPost,
    type: post.type,
    description: partialPost.description ?? null,
    categories: partialPost.categories || [],
  }
}

formatPostPreview uses Contentlayer's pick utility to select only the fields a listing card needs. No body content, no headings, no tweet IDs. This keeps the JSON payload small for pages that render many posts.

The more interesting one is getPartialPost:

lib/contentlayer.ts
// Strip unnecessary fields from post data for client-side rendering
// The biggest culprit is post.body.raw (the raw MDX source) — 10-20KB per post
// Only body.code (compiled React JSX) is needed for rendering
export const getPartialPost = (
  { title, slug, publishedAtFormatted, description, body, headings, categories, series }: Post,
  allPosts?: Post[],
) => ({
  title,
  slug,
  publishedAtFormatted,
  description: description ?? null,
  body: {
    code: body.code, // Only compiled JSX, NOT body.raw
  },
  headings: (headings as { heading: number; text: string; slug: string }[]) ?? null,
  categories: categories?.map((category) => ({ title: category.title, slug: category.slug })) ?? [],
  series:
    series && allPosts
      ? {
          title: series.title,
          posts: allPosts
            .filter((p) => p.series?.title === series.title)
            .sort((a, b) => Number(new Date(a.series!.order)) - Number(new Date(b.series!.order)))
            .map((p) => ({
              title: p.title,
              slug: p.slug,
              status: p.status,
              isCurrent: p.slug === slug,
            })),
        }
      : null,
})

Why strip body.raw? Each post's body.raw is the full MDX source text — typically 10-20KB. It's only useful for re-processing the MDX, which we never do client-side. The body.code field is the compiled React JSX — already processed through the entire remark/rehype pipeline. It's what useMDXComponent needs to render the post. By excluding body.raw, we cut the serialized props sent to the client roughly in half per post.

The series processing is also worth noting. It takes the current post's series metadata, finds all other posts in the same series, sorts them by order, and marks which one is the current post. This data powers the series navigation component we'll look at later.

MDX Components

This is where the raw MDX source becomes styled, interactive HTML. The processing pipeline has five stages.

The Pipeline

MDX Source
→ remark-gfm (GitHub Flavored Markdown: tables, strikethrough, task lists)
→ rehype-slug (Add IDs to headings for TOC linking)
→ rehype-pretty-code (Syntax highlighting with Shiki)
→ rehypePrettyCodeClasses (Custom plugin: add Tailwind classes to code blocks)
→ rehype-autolink-headings (Wrap headings in <a> tags with "#" prefix on hover)

This is configured in contentlayer.config.ts:

contentlayer.config.ts
import { makeSource } from "contentlayer/source-files"
import rehypeAutolinkHeadings from "rehype-autolink-headings"
import rehypePrettyCode from "rehype-pretty-code"
import rehypeSlug from "rehype-slug"
import remarkGfm from "remark-gfm"
import { Post } from "./content/definitions/Post"
import { Video } from "./content/definitions/Video"
import { HEADING_LINK_ANCHOR } from "./lib/constants"
import { rehypePrettyCodeClasses, rehypePrettyCodeOptions } from "./lib/rehypePrettyCode"
 
export default makeSource({
  contentDirPath: "content",
  documentTypes: [Post, Video],
  mdx: {
    esbuildOptions(options) {
      options.target = "esnext"
      return options
    },
    remarkPlugins: [[remarkGfm]],
    rehypePlugins: [
      [rehypeSlug],
      [rehypePrettyCode, rehypePrettyCodeOptions],
      [rehypePrettyCodeClasses],
      [
        rehypeAutolinkHeadings,
        {
          behavior: "wrap",
          properties: {
            className: [HEADING_LINK_ANCHOR],
          },
        },
      ],
    ],
  },
})

The order matters. rehype-slug must run before rehype-autolink-headings because the autolink plugin needs the IDs to already exist on the headings. rehype-pretty-code runs before rehypePrettyCodeClasses because our custom plugin needs the syntax-highlighted HTML structure to exist before it can add Tailwind classes to it.

Custom rehype Plugin: rehypePrettyCodeClasses

rehype-pretty-code generates HTML for code blocks, but it doesn't style them. That's our job. We wrote a custom rehype plugin that visits the AST twice — once for inline code, once for code blocks — and adds Tailwind classes to every relevant node.

lib/rehypePrettyCode.ts
import { type Options } from "rehype-pretty-code"
import { visit } from "unist-util-visit"
 
// CSS class constants for styling rehype-pretty-code output
// The HTML structure is: div.BLOCK > pre.PRE > code.CODE
const BLOCK =
  "overflow-hidden rounded-lg bg-foreground/5 shadow-surface-elevation-low ring-1 ring-foreground/[3%] ring-inset"
const TITLE =
  "mb-0.5 rounded-md bg-foreground/10 px-3 py-1 font-mono text-xs text-foreground/70 shadow-sm"
const PRE = "overflow-x-auto py-2 text-[13px] leading-6 [color-scheme:dark]"
const CODE = "grid [&>span]:border-l-4 [&>span]:border-l-transparent [&>span]:pl-2 [&>span]:pr-3"
const INLINE_BLOCK =
  "whitespace-nowrap border border-border/10 px-1.5 py-px text-[12px] rounded-full bg-card whitespace-nowrap text-accent/90"
const INLINE_CODE = ""
const NUMBERED_LINES =
  "[counter-reset:line] before:[&>span]:mr-3 before:[&>span]:inline-block before:[&>span]:w-4 before:[&>span]:text-right before:[&>span]:text-foreground/20 before:[&>span]:![content:counter(line)] before:[&>span]:[counter-increment:line]"
const HIGHLIGHTED_LINE = "!border-l-accent/70 bg-foreground/10 before:!text-foreground/70"
 
export function rehypePrettyCodeClasses() {
  return (tree: any) => {
    // First visit: Transform bare <code> elements (inline code) into styled spans
    visit(
      tree,
      (node: any) =>
        Boolean(
          node.tagName === "code" &&
            Object.keys(node.properties).length === 0 &&
            node.children.some((n: any) => n.type === "text"),
        ),
      (node: any) => {
        const textNode = node.children.find((n: any) => n.type === "text")
        textNode.type = "element"
        textNode.tagName = "code"
        textNode.properties = { className: [INLINE_CODE] }
        textNode.children = [{ type: "text", value: textNode.value }]
        node.properties.className = [INLINE_BLOCK]
        node.tagName = "span"
      },
    )
 
    // Second visit: Style code blocks and inline code fragments
    visit(
      tree,
      (node: any) =>
        Boolean(typeof node?.properties?.["data-rehype-pretty-code-fragment"] !== "undefined"),
      (node: any) => {
        if (node.tagName === "span") {
          node.properties.className = [...(node.properties.className || []), INLINE_BLOCK]
          node.children[0].properties.className = [
            ...(node.children[0].properties.className || []),
            INLINE_CODE,
          ]
          return node
        }
 
        if (node.tagName === "div") {
          node.properties.className = [...(node.properties.className || []), BLOCK]
          node.children = node.children.map((node: any) => {
            if (
              node.tagName === "div" &&
              typeof node.properties?.["data-rehype-pretty-code-title"] !== "undefined"
            ) {
              node.properties.className = [...(node.properties.className || []), TITLE]
            }
            if (node.tagName === "pre") {
              node.properties.className = [PRE]
              if (node.children[0].tagName === "code") {
                node.children[0].properties.className = [
                  ...(node.children[0].properties.className || []),
                  CODE,
                ]
                if (typeof node.children[0].properties["data-line-numbers"] !== "undefined") {
                  node.children[0].properties.className.push(NUMBERED_LINES)
                }
              }
            }
            return node
          })
          return node
        }
      },
    )
  }
}
 
export const rehypePrettyCodeOptions: Partial<Options> = {
  theme: "one-dark-pro",
  tokensMap: {
    fn: "entity.name.function",
    objKey: "meta.object-literal.key",
  },
  onVisitLine(node) {
    if (node.children.length === 0) {
      node.children = [{ type: "text", value: " " }]
    }
    node.properties.className = [""]
  },
  onVisitHighlightedLine(node) {
    node.properties.className.push(HIGHLIGHTED_LINE)
  },
}

The first visit targets bare <code> elements — these are inline code spans in your markdown (like const x = 1). It wraps them in a styled <span> with a pill-shaped border.

The second visit targets data-rehype-pretty-code-fragment nodes — these are the code blocks that rehype-pretty-code generated. It distinguishes between <span> (inline) and <div> (block) fragments and applies the appropriate classes.

The tokensMap option is a nice touch. It lets you use shorthand annotations in your markdown like {fn} to highlight a token as a function name, or {objKey} for an object key. These map to VS Code token scopes so the highlighting matches your editor.

MdxComponents.tsx

The pipeline transforms the MDX source into React JSX. But you also need to tell React which components to use for each HTML element. That's what MdxComponents.tsx does.

ui/MdxComponents.tsx
import { Aside } from "@/ui/mdx/Aside"
import { Code } from "@/ui/mdx/Code"
import { Hr } from "@/ui/mdx/Dividers"
import { FauxTweet } from "@/ui/mdx/FauxTweet"
import { Files } from "@/ui/mdx/Files"
import { H1, H2, H3, H4 } from "@/ui/mdx/Headings"
import { Img } from "@/ui/mdx/Img"
import { A } from "@/ui/mdx/Links"
import { LikeButton2 } from "@/ui/mdx/LikeButton2"
import { LikeButtonDemo } from "@/ui/mdx/LikeButtonDemo"
import { LoadingSkeleton } from "@/ui/mdx/LoadingSkeleton"
import { Ol, Ul } from "@/ui/mdx/Lists"
import { Playground } from "@/ui/mdx/Playground"
import { StaticTweet } from "@/ui/mdx/StaticTweet"
import { Blockquote, Del, Strong } from "@/ui/mdx/Text"
 
export const components = {
  // Custom components available in MDX (via <ComponentName /> syntax)
  LoadingSkeleton,
  Playground,
  Code,
  Files,
  FauxTweet,
  StaticTweet,
  LikeButton2,
  LikeButtonDemo,
  Aside,
 
  // Heading remap: h1→h2, h2→h3, h3→h4, h4→h5
  h1: H1,
  h2: H2,
  h3: H3,
  h4: H4,
 
  // Block elements
  hr: Hr,
  blockquote: Blockquote,
 
  // Inline elements
  a: A,
  ul: Ul,
  ol: Ol,
  strong: Strong,
  del: Del,
 
  // Image component with optional bleed and caption
  Img,
}

The heading remap is important. In your MDX files, you write # Heading (h1), but it renders as <h2> in the HTML. Why? Because the page title (post.title) is already the h1. If your MDX content also had h1 elements, you'd have multiple h1s on the page — bad for accessibility and SEO. So everything gets bumped down one level.

The custom components like Code, Files, Aside, and Playground are what make the blog posts interactive. You can use them directly in your MDX:

<Code>```ts title="example.ts" const hello = "world" ```</Code>

Blog Detail Page

This is the page that renders a single blog post. It's a dynamic route at pages/posts/[slug].tsx.

Static Generation

pages/posts/[slug].tsx
import { getPartialPost } from "@/lib/contentlayer"
import { createOgImage } from "@/lib/createOgImage"
import { siteConfig } from "@/site.config"
import { useLang, type Lang } from "@/lib/useLang"
import { useActiveHeading } from "@/lib/useActiveHeading"
import { Layout } from "@/ui/Layout"
import { components } from "@/ui/MdxComponents"
import { PostSeries } from "@/ui/PostSeries"
import { TocNavHeader } from "@/ui/TocNavHeader"
import { allPosts } from "contentlayer/generated"
import { GetStaticProps, InferGetStaticPropsType } from "next"
import { useMDXComponent } from "next-contentlayer/hooks"
import { NextSeo } from "next-seo"
import Link from "next/link"
import { useRouter } from "next/router"
 
// Generate all blog post pages at build time — one path per slug (not per lang)
export const getStaticPaths = () => {
  const slugs = Array.from(new Set(allPosts.map((p) => p.slug)))
  return {
    paths: slugs.map((slug) => ({ params: { slug } })),
    fallback: false,
  }
}
 
// Fetch both language versions of a post
export const getStaticProps: GetStaticProps<{
  posts: Record<Lang, ReturnType<typeof getPartialPost>>
}> = async ({ params }) => {
  const matchingPosts = allPosts.filter((post) => post.slug === params?.slug)
 
  if (matchingPosts.length === 0) {
    return { notFound: true }
  }
 
  // Build a map of lang → post
  const postsByLang: Record<string, ReturnType<typeof getPartialPost>> = {}
  for (const post of matchingPosts) {
    postsByLang[post.lang] = getPartialPost(post, allPosts)
  }
 
  // Always ensure English exists (fallback)
  if (!postsByLang["en"]) {
    return { notFound: true }
  }
 
  return {
    props: {
      posts: postsByLang as Record<Lang, ReturnType<typeof getPartialPost>>,
    },
  }
}

Notice that getStaticPaths generates one path per slug, not per language variant. The slug hello-world is the same for both hello-world.en.mdx and hello-world.zh.mdx. The getStaticProps function then finds all posts matching that slug and builds a language-keyed map. This way a single page serves both languages — the client picks which one to render based on the user's language preference.

The getPartialPost call strips body.raw (as we discussed earlier) and enriches the series data with all posts in the same series.

The Page Component

pages/posts/[slug].tsx
export default function PostPage({ posts }: InferGetStaticPropsType<typeof getStaticProps>) {
  const { lang } = useLang()
  const router = useRouter()
 
  // Select post in preferred language, fallback to English
  const post = posts[lang] || posts["en"]
  const MDXContent = useMDXComponent(post.body.code)
  const toc = useActiveHeading(post.headings ?? [], { levels: [1, 2] })
 
  const path = `/posts/${post.slug}`
 
  // Build SEO metadata
  const url = `https://${siteConfig.domain}${path}`
  const title = `${post.title} | ${siteConfig.domain}`
  const ogImage = createOgImage({
    title: post.title,
    meta: `${siteConfig.domain} · ` + post.publishedAtFormatted,
  })
 
  return (
    <>
      <NextSeo
        title={title}
        description={post.description ?? undefined}
        canonical={url}
        openGraph={{
          url,
          title,
          description: post.description ?? undefined,
          images: [
            {
              url: ogImage,
              width: 1600,
              height: 836,
              alt: post.title,
            },
          ],
        }}
      />
 
      <Layout navRight={toc.isTocMode ? <TocNavHeader {...toc} /> : undefined}>
        <div className="xl:!col-end-5">
          <h1 className="text-foreground text-2xl font-medium sm:text-3xl">{post.title}</h1>
 
          <div className="text-muted-foreground mt-2 flex flex-wrap items-center space-x-2 text-lg">
            <div>{post.publishedAtFormatted}</div>
            {post.categories.length > 0 ? (
              <>
                <div className="text-foreground/30">·</div>
                <div className="flex space-x-2">
                  {post.categories.map((category) => (
                    <Link
                      key={category.slug}
                      href={`/categories/${category.slug}`}
                      className="text-muted-foreground hover:text-foreground/80"
                    >
                      {category.title}
                    </Link>
                  ))}
                </div>
              </>
            ) : null}
          </div>
        </div>
 
        {/* Series navigation above content */}
        {post.series && post.series.posts.length > 1 ? (
          <PostSeries data={post.series} isInteractive={true} />
        ) : null}
 
        {/* Render MDX content with custom component map */}
        <MDXContent components={components} />
 
        {/* Series navigation below content */}
        {post.series && post.series.posts.length > 1 ? (
          <div className="mt-16">
            <PostSeries data={post.series} />
          </div>
        ) : null}
      </Layout>
    </>
  )
}

Let me call out the key parts.

SEO and OG images. The NextSeo component from next-seo handles all the meta tags. The OG image is generated dynamically via Cloudinary — it takes the post title and domain/date, renders them onto a template image, and returns a URL. This means every post gets a unique social preview card without needing to pre-generate images.

TOC sidebar. The useActiveHeading hook uses two IntersectionObserver instances to track which heading is currently in view. The first observer watches the page title (h1) — when it scrolls out of view, isTocMode becomes true and the TocNavHeader component appears in the navigation bar. The second observer watches all h2 headings and tracks which one is in the "active zone" (the top 20-40% of the viewport). This gives you a live-updating table of contents in the nav.

Series navigation. If the post is part of a series, a PostSeries component appears both above and below the content. The one above is interactive (collapsible), the one below is always expanded.

MDX rendering. The useMDXComponent hook takes the compiled JSX from body.code and returns a React component. We pass our components map to it, which remaps all the HTML elements to our custom implementations.

Home Page with Post Listing

The home page needs a reusable card component for showing post previews. I built ContentLink as a compound component — a pattern where you have a parent component with dot-notation sub-components.

ui/ContentLink.tsx
import { FOCUS_VISIBLE_OUTLINE } from "@/lib/constants"
import cx from "clsx"
import Link from "next/link"
import React, { ElementType } from "react"
 
export function ContentLink({ href, children }: { href: string; children: React.ReactNode }) {
  return (
    <Link
      href={href}
      className={cx(
        "bg-card block overflow-hidden rounded-2xl p-7 shadow-sm transition duration-300 hover:shadow-md",
        FOCUS_VISIBLE_OUTLINE,
      )}
    >
      {children}
    </Link>
  )
}
 
function Title({ children }: { children: React.ReactNode }) {
  return (
    <h3 className="line-clamp-2 text-foreground/90 hover:text-foreground text-xl transition duration-300">
      {children}
    </h3>
  )
}
 
function Icon(props: { icon: ElementType }) {
  return (
    <div className="ml-2 mt-1 shrink-0">
      <props.icon className="text-foreground/30 hover:text-foreground/50 w-5 transition-colors" />
    </div>
  )
}
 
function Text({ children }: { children: React.ReactNode }) {
  return <p className="line-clamp-3 text-foreground/70 mt-4 text-lg">{children}</p>
}
 
function Meta({ children }: { children: React.ReactNode }) {
  return <div className="text-foreground/60 flex flex-wrap space-x-2 text-base">{children}</div>
}
 
ContentLink.Title = Title
ContentLink.Icon = Icon
ContentLink.Text = Text
ContentLink.Meta = Meta

Usage looks like this:

<ContentLink href={`/posts/${post.slug}`}>
  <ContentLink.Title>{post.title}</ContentLink.Title>
  <ContentLink.Text>{post.description}</ContentLink.Text>
  <ContentLink.Meta>
    <span>{post.publishedAtFormatted}</span>
    {post.categories.map((cat) => (
      <span key={cat.slug}>{cat.title}</span>
    ))}
  </ContentLink.Meta>
</ContentLink>

Why a compound component instead of a single <PostCard> with props? Flexibility. Some cards might show an icon, some might not. Some might have a meta section, some might skip it. The compound pattern lets the parent page compose the card however it needs, without the card component having to handle every possible variation through conditional props.

The line-clamp-2 and line-clamp-3 classes truncate long titles and descriptions with an ellipsis. The bg-card and text-foreground classes use the semantic CSS custom properties from our theme system — so the cards automatically adapt to light and dark mode.

Blog Listing Page

The blog listing page at /blog reuses the same ContentLink component. It's a straightforward page that fetches all published posts and renders them as a grid of cards.

pages/blog/index.tsx
import { allPosts, type Post } from "contentlayer/generated"
import { formatPostPreview } from "@/lib/contentlayer"
import { type GetStaticProps, type InferGetStaticPropsType } from "next"
import { ContentLink } from "@/ui/ContentLink"
import { Layout } from "@/ui/Layout"
 
export const getStaticProps: GetStaticProps = () => {
  const posts = allPosts
    .filter((p) => p.status === "published" && p.lang === "en")
    .map(formatPostPreview)
    .sort((a, b) => Number(new Date(b.publishedAt)) - Number(new Date(a.publishedAt)))
 
  return { props: { posts } }
}
 
export default function BlogPage({ posts }: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <Layout>
      <h1 className="text-foreground text-3xl font-bold">Blog</h1>
      <div className="mt-8 space-y-4">
        {posts.map((post) => (
          <ContentLink key={post.slug} href={`/posts/${post.slug}`}>
            <ContentLink.Title>{post.title}</ContentLink.Title>
            <ContentLink.Text>{post.description}</ContentLink.Text>
            <ContentLink.Meta>
              <span>{post.publishedAtFormatted}</span>
            </ContentLink.Meta>
          </ContentLink>
        ))}
      </div>
    </Layout>
  )
}

Simple, clean, and it reuses all the pieces we've already built. The formatPostPreview helper strips the post down to just the fields the card needs.

Category Filtering

Categories are how users discover related content. Instead of freeform tags, I used a closed enum — a fixed set of category names and slugs.

Category Type

content/definitions/Category.ts
import { defineNestedType } from "contentlayer/source-files"
import { allCategoryNames, allCategorySlugs } from "../../lib/contentlayer"
 
export const Category = defineNestedType(() => ({
  name: "Category",
  fields: {
    title: {
      type: "enum",
      required: true,
      options: allCategoryNames,
    },
    slug: {
      type: "enum",
      required: true,
      options: allCategorySlugs,
    },
  },
}))

This is a nested type — it doesn't get its own pages or content files. It lives inside a Post's frontmatter:

categories:
  - title: "MDX"
    slug: "mdx"
  - title: "Next.js"
    slug: "next"

The enum constraint means Contentlayer will warn you if you typo a category name. "MDX" is valid. "mdx" (as a title) is not. This prevents the gradual drift you get with freeform tags where you end up with "javascript", "JavaScript", "js", and "JS" all as separate categories.

The allowed values are defined in lib/contentlayer.ts:

export const allCategoryNames = ["Next.js", "MDX", "Next Conf", "React Conf"]
export const allCategorySlugs = ["next", "mdx", "next-conf", "react-conf"]

URL Patterns

Categories generate these URL patterns:

  • /categories — Category index page showing all categories as clickable pills
  • /categories/:slug — All content filtered by a specific category (e.g., /categories/mdx)
  • /posts/categories/:slug — Only posts filtered by category
  • /videos/categories/:slug — Only videos filtered by category

All of these are handled by a single catch-all route, which we'll look at in the last section.

Category Index Page

The category index page renders all available categories as clickable pills:

<div className="flex flex-wrap gap-3">
  {allCategoryNames.map((name, index) => (
    <Link
      key={allCategorySlugs[index]}
      href={`/categories/${allCategorySlugs[index]}`}
      className="border-border/10 text-accent hover:border-accent/40 rounded-full border px-4 py-2 transition-colors"
    >
      {name}
    </Link>
  ))}
</div>

Each pill links to the filtered view for that category. The rounded-full class makes them pill-shaped, and the border-border/10 uses a subtle border from the theme system.

Category Filter Indicator

When viewing a filtered page, a filter indicator shows at the top:

{
  currentFilters?.category ? (
    <div className="flex items-center space-x-2">
      <span className="text-muted-foreground">Filtering by:</span>
      <span className="border-accent/60 text-accent rounded-full border px-4 py-2">
        {currentFilters.category}
      </span>
      <Link
        href={currentFilters?.type === "videos" ? "/videos/categories" : "/categories"}
        className="text-muted-foreground hover:text-foreground/80"
      >
        (clear)
      </Link>
    </div>
  ) : null
}

The "(clear)" link navigates back to the category index, letting users quickly switch between filters.

Series Navigation

Some blog posts are part of a multi-part series (like this one). The Series nested type groups them together.

Series Type

content/definitions/Series.ts
import { defineNestedType } from "contentlayer/source-files"
 
export const Series = defineNestedType(() => ({
  name: "Series",
  fields: {
    title: {
      type: "string",
      required: true,
    },
    order: {
      type: "number",
      required: true,
    },
  },
}))

In a post's frontmatter:

series:
  order: 3
  title: "Built My Personal Website with Next.js"

The order field determines the position of this post in the series. The title groups all posts with the same series title together.

PostSeries Component

The PostSeries component renders a collapsible navigation box that shows all posts in the series, with the current post highlighted.

ui/PostSeries.tsx
import { FOCUS_VISIBLE_OUTLINE, LINK_STYLES } from "@/lib/constants"
import { getPartialPost } from "@/lib/contentlayer"
import cx from "clsx"
import Link from "next/link"
import React from "react"
import ChevronDownIcon from "@heroicons/react/outline/ChevronDownIcon"
import ChevronUpIcon from "@heroicons/react/outline/ChevronUpIcon"
 
const Title = ({ children }: { children: React.ReactNode }) => {
  return (
    <div>
      <div className="text-muted-foreground text-sm uppercase">Series</div>
      <div className="text-foreground text-lg font-medium sm:text-xl">{children}</div>
    </div>
  )
}
 
export const PostSeries = ({
  data,
  isInteractive = false,
}: {
  data: NonNullable<ReturnType<typeof getPartialPost>["series"]>
  isInteractive?: boolean
}) => {
  const [isOpen, setIsOpen] = React.useState(!isInteractive)
  const currentIndex = data.posts.findIndex((post) => post.isCurrent) + 1
 
  return (
    <div className="bg-card rounded-2xl p-5 shadow-sm lg:px-8 lg:py-7">
      {isInteractive ? (
        <button
          className="group flex w-full items-center text-left"
          onClick={() => {
            setIsOpen(!isOpen)
          }}
        >
          <Title>
            {data.title}
            <span className="text-muted-foreground font-normal">
              {" "}
              &middot; {currentIndex} of {data.posts.length}
            </span>
          </Title>
 
          <div className="ml-auto pl-4">
            <div className="bg-foreground/10 text-card group-hover:bg-foreground/25 rounded-full p-2">
              {isOpen ? <ChevronUpIcon className="w-5" /> : <ChevronDownIcon className="w-5" />}
            </div>
          </div>
        </button>
      ) : (
        <Title>{data.title}</Title>
      )}
 
      <div
        className={cx({
          hidden: !isOpen,
          block: isOpen,
        })}
      >
        <hr className="border-border/5 my-5 border-t-2" />
 
        <ul className="text-base">
          {data.posts.map((p) => (
            <li
              key={p.slug}
              className={cx(
                "relative my-3 pl-7 before:absolute before:left-1 before:top-[9px] before:h-1.5 before:w-1.5 before:rounded-full",
                {
                  "before:bg-primary before:ring/20 before:ring-offset-background before:ring-[3px] before:ring-offset-1":
                    p.isCurrent,
                  "before:bg-foreground/30": p.status === "published" && !p.isCurrent,
                  "before:bg-foreground/10": p.status !== "published",
                },
              )}
            >
              {p.status === "published" ? (
                p.isCurrent ? (
                  <span className="text-foreground">{p.title}</span>
                ) : (
                  <Link
                    href={`/posts/${p.slug}`}
                    className={cx(LINK_STYLES, FOCUS_VISIBLE_OUTLINE)}
                  >
                    {p.title}
                  </Link>
                )
              ) : (
                <span className="text-muted-foreground">{p.title}</span>
              )}
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

There are a few things worth highlighting here.

The isInteractive prop. When true, the component renders a toggle button with a chevron icon. Clicking it expands or collapses the post list. When false, the list is always visible. The blog detail page uses the interactive version above the content and the static version below it.

The dot indicators. Each post in the series gets a colored dot via Tailwind's before: pseudo-element utilities:

  • bg-primary with a ring — the current post (your position in the series)
  • before:bg-foreground/30 — published posts you can navigate to
  • before:bg-foreground/10 — unpublished (draft) posts, shown but dimmed

Current post detection. The isCurrent flag comes from getPartialPost, which compares each post's slug to the current post's slug. The currentIndex variable finds the position of the current post in the sorted list for the "2 of 5" counter.

Catch-All Route

This is the most architecturally interesting part of Phase 2. Instead of creating separate page files for every route variation, I used a catch-all route — a single [[...filter]].tsx file that handles multiple URL patterns.

Why a Catch-All?

Consider all the routes we need:

RouteWhat it shows
/Home page with hero + all content
/postsPosts only
/categoriesCategory index (all categories as pills)
/categories/:slugAll content filtered by category
/posts/categories/:slugPosts filtered by category
/videosVideos only
/videos/categories/:slugVideos filtered by category

That's seven distinct routes. Without a catch-all, you'd need seven separate page files with a lot of duplicated logic. The catch-all route handles all of them with one getStaticPaths and one getStaticProps.

getStaticPaths

pages/[[...filter]].tsx
import { allCategorySlugs, allCategoryNames } from "@/lib/contentlayer"
import { siteConfig } from "@/site.config"
 
export const getStaticPaths = () => {
  const paths = [
    // Home page
    { params: { filter: [] } },
    // Posts listing
    { params: { filter: ["posts"] } },
    // Category index page (shows all categories)
    { params: { filter: ["categories"] } },
    // Posts category index page (shows post categories)
    { params: { filter: ["posts", "categories"] } },
    // Category filter pages (one per category)
    ...allCategorySlugs.map((slug) => ({ params: { filter: ["categories", slug] } })),
    // Posts category filter pages
    ...allCategorySlugs.map((slug) => ({ params: { filter: ["posts", "categories", slug] } })),
    // Video routes (conditional on feature flag)
    ...(siteConfig.features.video
      ? [
          { params: { filter: ["videos"] } },
          { params: { filter: ["videos", "categories"] } },
          ...allCategorySlugs.map((slug) => ({
            params: { filter: ["videos", "categories", slug] },
          })),
        ]
      : []),
  ]
 
  return {
    paths,
    fallback: false,
  }
}

The filter parameter is an array. For the home page, it's empty ([]). For /categories/mdx, it's ["categories", "mdx"]. For /posts/categories/next, it's ["posts", "categories", "next"].

Notice that video routes are gated behind a feature flag (siteConfig.features.video). This lets you disable the video section entirely without removing any code — just flip the flag and the paths aren't generated.

getStaticProps

pages/[[...filter]].tsx
import {
  allCategorySlugs,
  allCategoryNames,
  formatPostPreview,
  formatVideoPreview,
  buildTranslations,
  type Translations,
} from "@/lib/contentlayer"
import { CurrentFilters } from "@/lib/types"
import { allPosts, allVideos, Category } from "contentlayer/generated"
import type { GetStaticProps } from "next"
 
export const getStaticProps: GetStaticProps<{
  currentFilters: CurrentFilters
  posts: (ReturnType<typeof formatVideoPreview> | ReturnType<typeof formatPostPreview>)[]
  translations: Translations
}> = async ({ params }) => {
  // Build translation map from all posts (before deduplication)
  const translations = buildTranslations(allPosts.filter((p) => p.status === "published"))
 
  // Deduplicate posts by slug — keep English version for listing
  const publishedPosts = allPosts.filter((p) => p.status === "published")
  const enPosts = publishedPosts.filter((p) => p.lang === "en")
  const dedupedSlugs = new Set(enPosts.map((p) => p.slug))
  const fallbackPosts = publishedPosts.filter((p) => !dedupedSlugs.has(p.slug))
  const uniquePosts = [...enPosts, ...fallbackPosts]
 
  // Merge videos (if enabled) and posts, sorted by date (newest first)
  let posts = [
    ...(siteConfig.features.video ? allVideos.map(formatVideoPreview) : []),
    ...uniquePosts.map(formatPostPreview),
  ].sort((a, b) => Number(new Date(b.publishedAt)) - Number(new Date(a.publishedAt)))
 
  let currentFilters: CurrentFilters = null
 
  // Apply filters based on URL params
  if (params?.filter && Array.isArray(params.filter)) {
    currentFilters = {}
 
    let category: Category["slug"] | undefined
 
    // Handle /videos route
    if (params.filter[0] === "videos") {
      posts = posts.filter((p) => p.type === "Video")
      currentFilters.type = "videos"
 
      if (params.filter[1] === "categories") {
        if (!params.filter[2]) {
          currentFilters.type = "video-category-index"
        } else {
          category = params.filter[2] as Category["slug"]
        }
      }
    }
    // Handle /posts route
    else if (params.filter[0] === "posts") {
      posts = posts.filter((p) => p.type === "Post")
      currentFilters.type = "posts"
      if (params.filter[1] === "categories" && params.filter[2]) {
        category = params.filter[2] as Category["slug"]
      }
    }
    // Handle /categories and /categories/:slug routes
    else if (params.filter[0] === "categories") {
      if (!params.filter[1]) {
        currentFilters.type = "category-index"
      } else {
        category = params.filter[1] as Category["slug"]
      }
    }
 
    // Apply category filter if present
    if (category) {
      currentFilters.category = category
      posts = posts.filter((p) => p.categories.find((x) => x.slug === category))
    }
  }
 
  return { props: { posts, currentFilters, translations } }
}

The deduplication logic is worth explaining. Since posts can have multiple language variants (hello-world.en.mdx and hello-world.zh.mdx), listing pages should only show each slug once. The code keeps the English version as the canonical entry and falls back to another language if no English version exists.

The translations map is built before deduplication so it includes all language variants. This lets listing pages show translated titles and descriptions when the user switches languages.

The Page Component

pages/[[...filter]].tsx
import { siteConfig } from "@/site.config"
import { CurrentFilters } from "@/lib/types"
import { useLang } from "@/lib/useLang"
import { PostPreview } from "@/ui/PostPreview"
import { Layout } from "@/ui/Layout"
import { Navigation } from "@/ui/Navigation"
import { ProfileImage } from "@/ui/ProfileImage"
import { VideoPostPreview } from "@/ui/VideoPostPreview"
import cx from "clsx"
import type { InferGetStaticPropsType } from "next"
import { NextSeo } from "next-seo"
import Link from "next/link"
import React from "react"
import { useIntersection } from "react-use"
 
export default function Home({
  posts,
  currentFilters,
  translations,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  const { lang } = useLang()
  // Track when hero section leaves viewport to show/hide navigation
  const intersectionRef = React.useRef(null)
  const intersection = useIntersection(intersectionRef, {
    root: null,
    rootMargin: "-100px",
  })
 
  // Show nav when: on filtered pages OR hero section is not visible
  let showNav = false
  if (currentFilters || (intersection && !intersection.isIntersecting)) {
    showNav = true
  }
 
  return (
    <>
      {currentFilters ? <NextSeo noindex={true} /> : null}
 
      <Layout showNav={showNav} currentFilters={currentFilters}>
        <div className="-mt-12 sm:mt-0">
          {/* Hero section — only visible on home page (no filters) */}
          <div ref={intersectionRef}>
            {!currentFilters ? (
              <div
                className={cx("transition duration-300", {
                  "opacity-0": showNav,
                  "opacity-100": !showNav,
                })}
              >
                <div className="flex items-center space-x-6">
                  <ProfileImage size="large" />
                  <div>
                    <h1 className="text-foreground/80 text-3xl font-medium sm:text-4xl">
                      {siteConfig.name}
                    </h1>
                    <h2 className="text-muted-foreground align-middle text-lg leading-6">
                      {siteConfig.meta}
                    </h2>
                  </div>
                </div>
 
                <p className="text-foreground mt-7 text-xl sm:mt-9">{siteConfig.description}</p>
 
                <div className="mt-8 sm:mt-12">
                  <Navigation showControls={false} />
                </div>
              </div>
            ) : null}
          </div>
 
          {/* Main content area */}
          <div className={cx("space-y-10", currentFilters ? "mt-8" : "mt-20 sm:mt-32")}>
            {/* Category index page */}
            {currentFilters?.type === "category-index" ? (
              <div className="space-y-8">
                <div>
                  <h1 className="text-foreground text-3xl font-bold">Categories</h1>
                  <p className="text-foreground/70 mt-2">Browse posts by category.</p>
                </div>
                <div className="flex flex-wrap gap-3">
                  {allCategoryNames.map((name, index) => (
                    <Link
                      key={allCategorySlugs[index]}
                      href={`/categories/${allCategorySlugs[index]}`}
                      className="border-border/10 text-accent hover:border-accent/40 rounded-full border px-4 py-2 transition-colors"
                    >
                      {name}
                    </Link>
                  ))}
                </div>
              </div>
            ) : null}
 
            {/* Category filter indicator */}
            {currentFilters?.category ? (
              <div className="flex items-center space-x-2">
                <span className="text-muted-foreground">Filtering by:</span>
                <span className="border-accent/60 text-accent rounded-full border px-4 py-2">
                  {currentFilters.category}
                </span>
                <Link
                  href={currentFilters?.type === "videos" ? "/videos/categories" : "/categories"}
                  className="text-muted-foreground hover:text-foreground/80"
                >
                  (clear)
                </Link>
              </div>
            ) : null}
 
            {/* Post listing */}
            {currentFilters?.type !== "category-index" &&
            currentFilters?.type !== "video-category-index" ? (
              <>
                {posts.map((post) => {
                  if (post.type === "Video") {
                    return <VideoPostPreview key={post.youtube.id} {...post} />
                  }
 
                  if (post.type === "Post") {
                    const t = translations[post.slug]?.[lang]
                    return (
                      <PostPreview
                        key={post.slug}
                        {...post}
                        title={t?.title || post.title}
                        description={t?.description || post.description}
                      />
                    )
                  }
                })}
              </>
            ) : null}
          </div>
        </div>
      </Layout>
    </>
  )
}

Hero Section with Intersection Observer

The hero section (profile image, name, description, navigation links) only appears on the home page. But there's a nice UX detail: as you scroll down and the hero leaves the viewport, the navigation bar appears at the top. Scroll back up, and it disappears.

This is done with useIntersection from react-use:

const intersectionRef = React.useRef(null)
const intersection = useIntersection(intersectionRef, {
  root: null,
  rootMargin: "-100px",
})
 
let showNav = false
if (currentFilters || (intersection && !intersection.isIntersecting)) {
  showNav = true
}

The intersectionRef is attached to the hero container. When the hero scrolls more than 100px out of view (rootMargin: "-100px"), intersection.isIntersecting becomes false, and showNav flips to true. The hero section also fades out with an opacity transition to avoid a jarring visual jump.

On filtered pages (like /categories/mdx), there's no hero section at all, so showNav is always true — the nav is visible from the start.

noindex for Filtered Pages

Filtered pages get a noindex meta tag:

{
  currentFilters ? <NextSeo noindex={true} /> : null
}

This tells search engines not to index pages like /categories/mdx or /posts. You want the home page and individual blog posts indexed, but not the intermediate listing/filtering pages. This prevents duplicate content issues and keeps search results clean.

Wrapping Up

That's Phase 2. The blog is now fully functional:

  • Content is defined as type-safe MDX documents with computed fields for slugs, headings, and formatted dates
  • MDX processing runs through a five-stage pipeline that handles syntax highlighting, heading anchors, and Tailwind styling
  • Blog detail pages are statically generated with SEO metadata, dynamic OG images, a live-updating TOC, and series navigation
  • Post listings use a compound component pattern for flexible card layouts
  • Category filtering uses enum-constrained types to prevent taxonomy drift
  • Series navigation provides collapsible, context-aware navigation between related posts
  • A single catch-all route handles seven different URL patterns with shared filtering logic

Phase 3 adds the polish — dark mode, animations, performance optimizations, and the small details that make a site feel finished.