A personal blog — "things I've learned here and there." Posts are written as MDX files checked into the repo; there is no CMS, no database, and no runtime content fetching. The site is fully static aside from the MDX renderer running on the server.
Live title: sqsung. Author: Kyusung Sohn.
- Next.js 15 (App Router) + React 19
- TypeScript (strict), path alias
@/*→src/* - MDX via
@next/mdx+next-mdx-remote(v6) - Tailwind CSS v4 (via
@tailwindcss/postcss) withprettier-plugin-tailwindcss - next-themes for light/dark mode
- rehype-highlight +
highlight.js/styles/github-dark.cssfor code blocks - framer-motion, @heroicons/react for UI
- Dev server uses Turbopack
npm install
npm run dev # http://localhost:3000 (Turbopack)
npm run build # runs index-blogs, then next build
npm start # serve the production build
npm run index-blogs # regenerate the MDX index (see below)
npm run lint # ESLint (flat config, eslint-config-next)This is the single architectural decision that matters, so future-me: read this section first.
MDX is not parsed at request time. Instead, a script crunches every .mdx file during npm run build and writes the results to src/contents/generated/. The running app only reads those generated artifacts.
- Author writes
src/contents/<slug>.mdxwith frontmatter (see contract below). npm run index-blogs(run automatically bynpm run build) executessrc/scripts/build-index.ts, which:- Reads every
.mdxinsrc/contents/ - Skips posts whose frontmatter has
isPublished: false - Calls
next-mdx-remote/serializewithrehype-highlighton each post - Writes each compiled post as JSON to
src/contents/generated/compiled/<slug>.json - Writes a master index to
src/contents/generated/_blog-index.jsonwith:sortedPosts— post metadata sorted bycreatedAtdesctagToId/tagCounts— tag → post-id lookup (tags are uppercased)idToPost— post-id → metadata lookuptotalPosts,totalTags,lastBuilt
- Reads every
src/backend/posts.server.tsis the only module that reads these artifacts. It imports_blog-index.jsonas a static JSON import (bundled) and reads per-post JSON from disk withfs/promisesinsidegetPostById.
- Editing an MDX file and reloading the dev server isn't enough. Run
npm run index-blogs— otherwise the app still reads the old compiled JSON. (If something looks stale, this is almost always why.) - Never hand-edit anything under
src/contents/generated/. Regenerate it. isPublished: falseposts never reach the app, not even as drafts — they get filtered during indexing.
---
title: "Post title"
summary: "One-sentence description"
tags: ["Frontend", "Nextjs"] # uppercased by the indexer
createdAt: "2025-06-30" # ISO date, used for sort order
isPublished: true
---The post id is the filename without .mdx (e.g. 2025-06-30-on-demand-revalidation). Date-prefixed slugs are the convention, but only createdAt actually drives ordering.
src/
├─ app/ # Next.js App Router
│ ├─ layout.tsx # ThemeProvider, Inter + Source Code Pro fonts, Header/Footer shell
│ ├─ page.tsx # Landing — latest posts + "See All"
│ ├─ post/[id]/ # Single post (MDXRemote renders compiled JSON)
│ ├─ posts/[page]/ # Paginated post list
│ ├─ tagged/[tag]/[page]/ # Posts filtered by tag, paginated
│ ├─ tags/ # All tags with counts
│ ├─ about/
│ └─ globals.css
├─ backend/
│ └─ posts.server.ts # ONLY place that reads generated/*. All data access goes through here.
├─ components/
│ ├─ blog/ # BlogCard, BlogList, BlogContentRenderer, Tag, PaginationButtons, …
│ ├─ common/ # Header, Footer, ThemeToggler, BackButton, Loader, …
│ └─ about/
├─ constants/
│ ├─ posts.constant.ts # POSTS_PER_PAGE = 5, POST_TTL
│ └─ routes.constant.ts # ROUTES helpers — use these instead of hardcoding paths
├─ contents/
│ ├─ *.mdx # Post sources — edit these
│ ├─ bio/ # About-page content
│ └─ generated/ # BUILD OUTPUT. Do not edit. Regenerate with `npm run index-blogs`.
│ ├─ _blog-index.json
│ └─ compiled/<slug>.json
├─ lib/
│ └─ ThemeProvider.tsx # next-themes wrapper
├─ scripts/
│ └─ build-index.ts # The indexer. Run via `npm run index-blogs`.
├─ types/ # BlogMetadata, BlogIndex, Blog
└─ utils/
Public assets (images, diagrams, favicons) live in public/.
TypeScript has two configs: tsconfig.json for the app, and tsconfig.scripts.json for the indexer (it runs via ts-node, which needs a CommonJS-ish setup distinct from Next's bundler config).
Landing page (/):
app/page.tsx → getAllPosts(1) → slices blogIndex.sortedPosts → renders <BlogList>.
Single post (/post/[id]):
app/post/[id]/page.tsx → getPostMetadataById(id) for generateMetadata (title, OG tags) → getPostById(id) reads src/contents/generated/compiled/<id>.json from disk → passes mdxSource to <BlogContentRenderer> → client-side <MDXRemote> renders it with next/image injected as the Image component.
Tag pages (/tagged/[tag]/[page]):
getPostsByTag(tag, page) uppercases the tag, looks up IDs in tagToId, resolves via idToPost, slices by POSTS_PER_PAGE.
Tags index (/tags):
getTags() returns tagCounts directly.
Pagination is uniform: POSTS_PER_PAGE = 5 (src/constants/posts.constant.ts). Routes are always constructed via the ROUTES helpers in src/constants/routes.constant.ts — don't hardcode paths, and note that ROUTES.tagged lowercases the tag for the URL while the index stores tags uppercased.
- Create
src/contents/YYYY-MM-DD-slug.mdx. - Fill in the frontmatter (all five fields are required).
- If you reference images, drop them in
public/and use Next's<Image>(already wired intoMDXRemote). - Run
npm run index-blogs(or justnpm run build). npm run devto preview.- Commit both the
.mdxand the regeneratedsrc/contents/generated/**— the app needs the generated files at runtime.
To unpublish without deleting: set isPublished: false and re-run index-blogs.
- Tailwind v4 is PostCSS-only — no
tailwind.config.jsneeded (there's a minimaltailwind.config.tspresent but most config lives inglobals.css). - Theme switching (
next-themes) usessuppressHydrationWarningon<html>in the root layout; don't remove it or you'll get the dark-mode flash warning. blog-postis the class hook for MDX-rendered content styles; the rest of the UI uses Tailwind utilities directly.- Code blocks are themed by importing
highlight.js/styles/github-dark.cssin the post page. Highlighting itself runs at build time (rehype-highlightin the indexer), not at render.
Vercel. npm run build produces a fully static site plus the server components needed to render MDX. No env vars, no external services.
| I want to… | Edit |
|---|---|
| Write a new post | src/contents/*.mdx, then npm run index-blogs |
| Change posts-per-page | src/constants/posts.constant.ts |
| Change a route path | src/constants/routes.constant.ts |
| Tweak post metadata fields | src/types/blog.types.ts + src/scripts/build-index.ts + src/backend/posts.server.ts |
Change MDX components (e.g. custom <Callout>) |
src/components/blog/BlogContentRenderer.tsx (the components prop) |
| Change the code-highlight theme | Swap the highlight.js/styles/*.css import in src/app/post/[id]/page.tsx |
| Change rehype/remark plugins | src/scripts/build-index.ts (the serialize call) — then re-index |
| Header / Footer / theme toggle | src/components/common/ |