the cover image of blog post How to build a blog website with Next.js

How to build a blog website with Next.js

2025-01-07
9 min read

There are numerous templates and tools for building a blog post website, and many of which have full-fledged features and fancy styles. However, as a developer, I want to have a dedicated one for myself with my taste... and with a full control, so I decided to build one from scratch, with Next.js.

Why Next.js

Well, actually I used to use hexo for my personal blog and hosted it on github.io, it was decent, but it became a bit slow recently and I'd like to have more dynamic capability, i.e. I want to push my post somewhere without another building step then the public can see it instantly. General speaking, Server site generation would be more suitable for a static web site like a blog, but I prefer to make the project to be agnostic about the posts, Next.js also supports Server-side Rendering(SSR) and cache quite well so I'd like to give it a try.

Challenges

Building a Next.js web app from scratch is not hard with the help of create-next-app , the actuall challenges are listed as follows,

  1. I'm used to writing blog posts with Markdown format, so the app needs to be feed with markdown file and convert them to html (or react componenets).
  2. To help the reader better understand my blog posts, I need a post list page that provides an overview of posts, like a list or grid table.
  3. A live-editing environment, although I can use other tools like vmd to preview the markdown, it's best to reflect my updates directly to the blog post web app , which gives me a wysiwyg interface.

Let me walk you through them one by one.

Markdown to Html

There is an npm package react-markdown can render the markdown content to react component, don't mind the remark-gfm below, it just adds extra support for footnotes, strikethrough, etc.

import React from 'react' import {createRoot} from 'react-dom/client' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' const markdown = `Just a link: www.nasa.gov.` createRoot(document.body).render( <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown> )

Blog list page

The list page needs to display some meta information about each post, like title, date and tags. I can extract the meta info from markdown files with gray-matter and construct the list page. An exmaple of what gray-matter can do is as follows,

--- title: Hello slug: home --- <h1>Hello world!</h1>

above data will be converted to a json object as follows:

{ content: '<h1>Hello world!</h1>', data: { title: 'Hello', slug: 'home' } }

The whole architecture

image

The Next.js app will look up the folder that accommodates the markdown files via an environment variable, so basiclly it will be a runtime dynamic server side rendering strategy, that means whenever I have a new post I can directly update the markdown file, the web app doesn't need to be rebuilt, the latest content will be visible after the cache expires.

Dropping the Sever site generation(SSR) (i.e. generating the static site at the build time) is really a tradefoff between performance and workflow efficiency; but considering I don't have a CDN and the Next.js provides quite performant other cache mechanisms like Data Cache, it's good enough for my case.

The following code shows how I used the usntable_cache to avoid loading markdown from local folder repeatedly in a short time.

import { readFile, readdir } from 'node:fs/promises'; import path from 'path'; import { unstable_cache } from 'next/cache'; import { parseFrontMatter } from '@/app/lib/utils'; export async function getCachedMarkdownData(folder: string) { const cachedGetter = unstable_cache( async () => { return getMarkdownData(folder); }, [], { tags: ['/blogs'], revalidate: 60 * 60, // 1 hour cache } ); const posts = await cachedGetter(); // ... sorting return posts; } export async function getMarkdownData(folder: string): Promise<Post[]> { const files = await readdir(folder); const markdownPosts = files.filter((file: string) => file.endsWith('.md')); const postsData = await Promise.all( markdownPosts.map(async (file: string) => { const filePath = path.join(folder, file); const doc = await readFile(filePath, 'utf8'); const { data, content } = parseFrontMatter(doc); return { ...data, slug: file.replace('.md', ''), content: content, }; }) ); return postsData; }

live-editing

Live editing means if the markdown changes the blog page needs to refresh accordingly. My first idea is adding a repeatedly refreshing with setInterval from the client side javascript. It should work but it's less efficient as it's a polling strategy. While there is no change in the markdown file many unnecessary requests are still triggered.

Watching the fils changes of local markdown are easy because the Node.js provides fs.watch function out of box. The only thing I need to tell the Next.js app is that it needs to refresh.

Wait! Isn't Next.js app already with an auto-refresh feature for development mode when you run npm run dev? whenever if you change the code in source file like page.tsx, it gets auto-refreshed. How does it work? Looking into how fast refresh works, I found actually it is a react feature allows live loading, and if I can trigger the change of a file that is imported by component or a page, it can be updated automatically.

That comes out a solution, the main flow will be like below, image

First, define a refreshTrigger function somehwere, let's say put it to a refresh-beacon.ts file, it can return anything, the only purpose of this function is to make sure it's content change will trigger the fast refresh.

export default function refreshTriggger() { return 'template'; }

Then, import it from your page.tsx and call it somewhere.

import refreshTrigger from '@/app/lib/refresh-beacon'; export default async function Page(props: { params: Promise<{ slug: string }> }) { ... refreshTrigger(); ... return ( <main> ... </main> ) }

Last, in the instrumentation.ts, call the startWatch funcion below,

export async function startWatch(folder: string) { // use debounce to dedup the consecutive file changes in a short time const trigger = debounce(changeTriggerSourceFile, 2); try { const watcher = watch(folder, { signal }); for await (const event of watcher) { if (!event.filename?.endsWith('md')) { continue; } await trigger(event.filename); } } catch (err: unknown) { ... } } async function changeTriggerSourceFile(changedFile: string) { const __dirname = dirname(fileURLToPath(import.meta.url)); const triggerPath = resolve(__dirname, './refresh-beacon.ts'); const contents = await readFile(triggerPath, { encoding: 'utf8' }); // embeding a timestamp to ensure changes happen even for the same file const newSeed = `${dayjs().unix()}_${changedFile}`; const newContents = contents.replace(/return .*/, `return '${newSeed}'; `); // TODO caveat, it is unsafe to write to the same file multiple times before promsie settled // but here it is a tiny file, should be fast enough await writeFile(triggerPath, newContents); }

The complete code has been published on github repo: https://github.com/xavierchow/xblog . Stars and feedback are always welcomed!

© 2025 Xavier Zhou