Building a NextJS blog
Full transparency here - this is not my first rodeo. I've built full-stack web apps before - granted, none beyond hobby, and without using React or Express in some capacity. It's been a while since I've touched web technology again though, so I've had some catching up to do.
These are my own opinions, synthesized by a third-year uni student (as of the time of writing). I am not affiliated with Vercel, nor am I being paid to write this article.
Tech-stack
React
Let me open with an admission: I'm not a long-time web-dev veteran. I've never used an alternative front-end framework1 like Angular or Vue, so I can't really speak to its competitors. But I've grown used to the React way of (de)structuring a front-end via components for composability and reusability, without compromising flexibility.
This is not to say that React doesn't suffer from its own pitfalls. There's been many instances where I'd just like a quick lookup of a strategy to implement a certain feature, only to be bombarded with more options than I could feasibly evaluate holistically and pick from.
For the purpose of this simple blog I'm sticking with React, and the majority of Stack Overflow 2024 survey responders seem to agree.
Tailwind
Technically, Tailwind is a standalone PostCSS plugin and isn't affiliated with NextJS, but I will include it here since bootstrapping a new project came with support for it out of the box. Admittedly, my initial thought of it was a healthy dose of skepticism, as with all previous styling technology introduced.
But what actually happened was a revolutionary shift in styling workflow.
The utilitary-first approach sounded like it would get awfully unwieldy at first, because it is actively going against the HTML vs CSS seperation mindset. And in a sense, this is true: if I wasn't using Visual Studio Code's automatic line-wrapping, almost all of my *.(j|t)sx files will grow a horizontal scrollbar. (If you're reading this blog, it's fair game to assume we both despise that).
Over time though, I eventually find the benefits of this unification outweigh its learning curve. The isolated nature of each Tailwind component allows me to style new components without worry of breaking established components, and I don't need to think about selector specifity at all.
The Tailwind documentation is also wonderfully written, and looking up CSS has never felt so good.
MDX
This next one might be a bit controversial, and to be honest I feel quite conflicted writing this. But to give credits where it's due, I do enjoy writing blogs in MDX, especially with how effortless it is to set custom components for the parsing engine.
The unified ecosystem, namely remark and rehype, makes the transformation from markdown to HTML straightforward, and the community-backed plugins selection has me browsing like a kid in front of a candy shop.
Taking advantage of NextJS' built-in capabilities
Search engine optimization
NextJS is built with search engine optimization (SEO) first. Generating metadata (i.e. the typical <head /> in an HTML document) is made simple with the Metadata API.
<head>
<title>Building an MDX-powered NextJS blog</title>
<meta name="description" content="Come build a blog with NextJS.">
</head>
Is equivalent to:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Building an MDX-powered NextJS blog",
description: "Come build a blog with NextJS.",
};
Directory-based routing
The concept is pretty straightforward. Your directory segments form your application's URL paths, and special reserved file names (layout, page, loading, error, etc.) serve as entry points/boundaries to conditionally render parts of your UI.
This blog has a pretty simple directory structure:
app/
|-layout.tsx
|-page.tsx
|-error.tsx
|-blogs/
|-layout.tsx
|-page.tsx
|-loading.tsx
|-error.tsx
|-[id]
|-layout.tsx
|-page.tsx
|-loading.tsx
|-not-found.tsx
The gist is as followed:
layout: wraps and renders its children underneath. Used for shared intra-navigation. If multiple wrapped layers of layout, will render all the way back to root.page: main entry point at[directory/to/file]. Renders into closestlayoutas{children}render prop. Will only render one at a time.loading: provides Suspense boundary for segment and below. If multiple defined, will render closest boundary.not-found: provides not-found boundary for segment and below. If multiple defined, will render closest boundary.error: provides error boundary for segment and below. If multiple defined, will render closest boundary.
Backend and frontend in a unified codebase
Before this blog, my experience with building backend was purely with ExpressJS, so the mindset of structuring routing into the same frontend application took some getting used to. Once it sunk in, it actually turned out to be a super simple way of writing quick API endpoints that serves a particular frontend use case.
To give an example, you could try opening a new browser tab and navigating to the endpoint /blogs/latest (or click on this link). This is a dynamic route; depending on the dates of all postings, this Route Handler will retrieve the [id] of the latest blog, and issue a 307: Redirect to /blogs/[latest-blog-id].
/blogs
|-[id]/
| |-page.tsx
|-latest/
|-route.ts
export async function GET(request: NextRequest) {
const blogMeta = await getAllBlogMeta();
// ...
const sortedBlogs = blogMeta.sort(
(a, b) =>
parseDateFromStr(b.date, "dd/mm/yyyy").getTime() -
parseDateFromStr(a.date, "dd/mm/yyyy").getTime(),
);
const latestBlog = sortedBlogs[0]; // grab latest blog ID
const url = request.nextUrl.clone();
url.pathname = `/blogs/${latestBlog.id}`; // redirect to latest blog
redirect(url.toString());
}
Challenges
There are many aspects I found challenging when picking up NextJS for the first time, especially for someone migrating over from React.
Note that this doesn't necessarily reflect the mentioned technology's complexity, just my personal experience learning/adapting them for use.
The information presented is extracted from the documentations of NextJS v15.0.2. For future references, technology might change, and APIs might be deprecated. Always check your current NextJS version and the corresponding documentations.
If I misunderstood anything, feel free to call me out via email.
RSC paradigm shift: SSG, SSR & ISR
The NextJS-way of writing interleaved server/client components was the biggest hurdle for me to wrap my head around. Granted, I stopped doing web development long before React Server Components (RSC) became a thing, so for me there was an additional mental load for breaking my previous assumptions of a client-only execution context.
In my understanding, RSC is a special type of React component that can be rendered at build time, to be shipped to a Content Delivery Network (CDN) for reduced bundle size and cheaper static hosting. This can also bring other benefits such as SEO2 or cumulative layout shift3 alleviation.
SSG
Static Site Generation (SSG), to put simply, is a practice that builds the entire page from the server and serves the HTML content as-is, without the need for serving additional JS files. This makes it ideal for static pages.
As I am building a single-tenant authored blog where all posts are known at build time, this is the natural choice. Despite blogs being served at dynamic paths of /blogs/[id], in NextJS we could enforce SSG for a provided set of known parameters with generateStaticParams().
export async function generateStaticParams() {
const posts = await getAllBlogMeta();
return posts.map((post) => ({
id: post.id,
}));
}
SSR
Server-side Rendering (SSR) is a slightly more dynamic approach to SSG. Whereas all parameters need to be known at build time for a site to be generated statically, any unknown parameter in an incoming request can trigger a build for that particular route.
Let's take an example. Say, at build time, I've written 3 blogs:
content/
|-blog1.mdx
|-blog2.mdx
|-blog3.mdx
And in generateStaticParams() I've hooked into the file system, read all files matching the extension *.mdx at the content/ directory, and return their respective file names (i.e. blog1) as dynamic slugs. This will enable SSG for the following URLs:
**/blogs/blog1
**/blogs/blog2
**/blogs/blog3
And calling any one of the above URLs will return the corresponding static HTML content, generated at build time.
What would happen if a client requests data at an unknown endpoint (e.g.
/blogs/new-content)?
This is where SSR comes into play. The server would lookup its Full Route Cache, and would get a CACHE MISS (as the route /blogs/new-content wasn't included at build time). It would then proceed to attempt rendering the requested content for the first time.
In my case, I've setup the dynamic route /blogs/[id] to try and locate a file with a matching slug name (i.e. content/new-content.mdx). Depending on whether the resource exists, it may return the dynamic blog's content, or redirect to a 404: Not Found.
export default async function BlogContentPage({
params,
}: {
params: { id: string };
}) {
const { id } = params; // extract dynamic slug from URL
const blogMeta = await getBlogMetaById(id); // lookup corresponding ${id}.mdx in file system
if (!blogMeta) return notFound();
// render the actual blog content...
}
Remember that this still happens on the server, just dynamically at request time. The default behavior is that static routes4 served once will then be cached in the Full Route Cache and persist across user requests. This means the served static page can then be safely distributed to CDNs.
ISR
Incremental Static Regeneration (ISR) is NextJS' implementation of the stale-while-revalidate caching strategy, and operates directly on cached values stored in its internal Data Cache.
To put simply, to strike a good balance between up-to-date data and fast response time, cached data is given a lifetime, after which they will automatically expire. Once a cache entry has expired:
- The stale cache is returned for its first request.
- The server fetches fresh data and updates the Data Cache. If this fetch fails, the stale data will be kept.
- Assuming the revalidation was successful, the next request will return fresh data.
Every time the Data Cache is invalidated, so will the client-side Router Cache, which in turn will trigger a server-side re-render, the output from which will be cached in the Full Route Cache (for static routes). In other words, ISR can be applied to control the frequency of SSR happening on the server.
You can use the Segment Config Options API to control the lifetime of individual fetch requests, or an entire cached route segments.
export const revalidate = 60; // revalidate every minute
In the context of this blog, it makes the most sense to simply disable SSR/ISR and enforce SSG-served data only, at least for the current version where all blogs are written to a local directory.
export const dynamic = "force-static"
Adding new blogs or updating existing content would require a complete redeploy anyway, which would invalidate the Full Route Cache regardless and ensure up-to-date data is served.
RSC-specific patterns
When writing interleaved server and client components the NextJS way, it's easy to forget they are executing in two entirely separate environment, (possibly) at two different time (build time if using SSG vs request time), each with access to platform-specific APIs that might not be available to the other.
Recommendations
Thankfully, NextJS provides some official patterns as a guideline.
- Nest RSC in client components via render props.
- Import client components into RSC.
- Keep client components lower in the tree.
The synopsis is that data flows one-way only, from server to client, and while an RSC can import client components, the opposite is not allowed.
Client-only approach
Let's take an example to illustrate this more clearly. Suppose we have a <ServerComponent /> we need to nest inside a <ClientComponent />, which also accepts a data prop that needs to be fetched from an API. Traditionally, without RSC, we would not have the <ServerComponent /> at all, and the <ClientComponent /> would likely be implemented like so:
import type Data from "@/model/types";
import { useState, useEffect } from "react";
import DataVisualizer from "./DataVisualizer";
export default function ClientComponent() {
const [data, setData] = useState<Data>();
useEffect(() => {
fetch(`https://data/endpoint/${params.id}`)
.then(res => setData(res.json()))
.catch(err => console.log(err));
}, [])
return <DataVisualizer data={data} />
}
With this approach, largest contentful paint (LCP) is often delayed, as the browser needs to wait for the client-side JS to fully load in before it even has access to the useEffect hook, then wait until the component finishes mounting to fire off the fetch event. Depending on how long the response takes, the total time until the client sees the main page content can be considerable, leading to a poorer UX and user retention rate.
Not only is this waterfall (i.e. sequential, blocking steps) approach not optimized for speed, it has a detrimental effect on SEO, as LCP itself can factor into the ranking algorithm. Content is also dynamically fetched at runtime, meaning crawler bots will not be able to index content for relevant keywords or categorize rich result.
NextJS approach
With RSC and NextJS, let's see an alternative implementation.
// server component by default
import ClientComponent from './client-component'
import ServerComponent from './server-component'
export default async function AppLayout(
{ params }: { params: string[] }) {
const data = await fetch(`https://data/endpoint/${params.id}`); // fetch data on server
return (
<ClientComponent data={data}>
<ServerComponent />
</ClientComponent>
)
}
"use client";
import type Data from "@/model/types";
import DataVisualizer from "./DataVisualizer";
export default function ClientComponent({ data, children }: { data: Data, children: React.ReactNode }) {
return (
<>
<DataVisualizer data={data} />
{children}
</>
)
}
Assuming the page isn't cached yet, upon request the server will initiate the data fetch and pre-render the <ServerComponent />. Once render is complete, the tree is passed down to the client, including data ready to use. The client will render only the <ClientComponent />, upon completion the page will immediately be ready, without further need of making a separate request.
If SSG or SSR/ISR is taken advantage of, this pre-render output will be cached, meaning subsequent requests will only consist of the initial client JS loading and client-side rendering. This is the full power of NextJS, if leveraged correctly.
Behind the scenes
But why is there this strict one-way data flow of server to client, and not the other way around? To answer that question, I find this direct quote from the NextJS documentation to be helpful:
During a request-response lifecycle, your code moves from the server to the client. If you need to access data or resources on the server while on the client, you'll be making a new request to the server - not switching back and forth.
For more insights, under the hood the server-rendered tree is presented in a format called RSC payload. This originates from the server, where client components are temporarily substituted with placeholders, that would later be swapped for real components upon hydration on the client side, the process of which is known as reconciliation5. A by-effect of this sequence is that all props passed from RSC to client components must be serializable6, otherwise data will be lost over the network transfer.
Pages vs App Router
With how fast the NextJS ecosystem is growing, it is actually very easy for a complete beginner to mistakenly refer to the wrong documentation versions, and get outdated information. I stumbled a lot over how to setup the initial directory-based routing with the App Router, as plenty of online guides doesn't explicitly point out they are still using the old Pages Router. (Granted, they were probably written back when the App Router wasn't a thing.)
Features-wise, most of the old functionality is mapped over directly 1-to-1, just under slightly different APIs. This has its pros and cons:
- Once I have a firm understanding of what I'm doing:
- I could still extract useful information out of old threads and discussion forums, swap out the old APIs for the new ones, and apply.
- I could identify the parallels between the old and new APIs, thus inferring a bit into how the framework implements certain features under the hood.
- However, the initial learning curve is also steeper:
- Using generative AI7 for a quick run-down is unreliable, since NextJS' documentations provide multiple methods of achieving the same task depending on which API is used.
- Some of the patterns popularized with the old API is actually no longer supported/recommended. For a newbie, it's difficult to tell which.
next-mdx-remote
While I appreciate that the NextJS documentations brought up using next-mdx-remote, this server-side MDX-to-HTML library doesn't have any client-side MDX dependency at all. Perhaps this should have been implied already, but I find it helpful to just explain this a tad more explicitly in the docs.
The library itself also provided little documentations on how to configure the parsed outcome of compileMDX() from its options, so the entire process was very much trial-and-error.
Plugin and component customization
To customize the parsed content, instead of installing remark and rehype plugins from next.config.mjs as you would for parsing client-side MDX, you'd pass this as an option when calling compileMDX().
import { readFileSync } from "fs";
import { compileMDX } from "next-mdx-remote/rsc";
import {
MDXComponentOverrides,
options,
} from "@/components/mdx-components";
export default async function readBlogByFilePath(filePath: string): Promise<Blog> {
const fileData = readFileSync(filePath);
const { content, frontmatter } = await compileMDX<BlogMeta>({
source: fileData,
options,
components: MDXComponentOverrides,
})
return {
...frontmatter,
content
} as Blog;
}
And install the plugins with any specific configurations in the options object.
import type { MDXRemoteProps } from "next-mdx-remote/rsc";
import remarkCodeTitles from "remark-flexible-code-titles";
import rehypeHighlight from "rehype-highlight";
import rehypeHighlightLines from "rehype-highlight-code-lines";
export const options: MDXRemoteProps["options"] = {
parseFrontmatter: true,
mdxOptions: {
remarkPlugins: [remarkCodeTitles],
rehypePlugins: [ // list all plugins and configurations
rehypeHighlight,
[
rehypeHighlightLines,
{
showLineNumbers: true,
},
],
],
},
};
Similarly, you can specify a mapping of custom JSX elements to use in place of a default parsed markdown element.
export const MDXComponentOverrides: MDXRemoteProps["components"] = {
h1: (props) => ( // render a styled <h1 /> instead of the default
<h1
{...props}
className="text-2xl font-semibold md:text-3xl xl:text-4xl"
/>
),
}
Styling plugin-generated components
There's a limitation to styling via element-tag mapping, particularly the inability to target specific elements based on plugin-generated class names.
Caveat: Tailwind's limitation
Using Tailwind's component-first utility styling in the componentOverrides will uniformly apply styling to all elements of the matching tag.
For example, take the rehype-toc plugin, which accepts the entire parsed HTML document and automatically generates a table of content.
import type { MDXRemoteProps } from "next-mdx-remote/rsc";
import toc from "rehype-toc";
export const options: MDXRemoteProps["options"] = {
mdxOptions: {
rehypePlugins: [toc],
},
};
Assume we leave all settings on default, when we inspect the HTML output, we get the following:
<nav class="toc">
<ol class="toc-level toc-level-1">
<li class="toc-item toc-item-h1">
<a class="toc-link toc-link-h1" href="#building-a-nextjs-blog">
Building a NextJS blog
</a>
</li>
</ol>
</nav>
The simplest way to style the individual heading line will be to override the <a /> tag from componentOverrides:
import type { MDXRemoteProps } from "next-mdx-remote/rsc";
import toc from "rehype-toc";
export const options: MDXRemoteProps["options"] = {
mdxOptions: {
rehypePlugins: [toc],
},
};
export const MDXComponentOverrides: MDXRemoteProps["components"] = {
a: (props) => (
<a
{...props}
className="text-sm no-underline hover:font-bold md:text-base"
/>
),
}
However, what will happen if we have another <a /> tag embedded within the article body, like a link?
# Hello, World!
I am a [Link]("https://random/link")
In this outcome, both <a /> tags will apply the custom styling uniformly, because we didn't specify any special behavior for elements with the .toc-link class name.
Workaround: CSS hybrid
Traditional CSS to the rescue!
li.toc-item a.toc-link {
@apply text-sm no-underline hover:font-bold md:text-base;
}
Once you've imported this separate stylesheet into your component, the document should format as expected. The componentOverrides-defined <a /> styling will still apply to all <a /> tags in the document, but since its specifity is lower than the CSS-defined overrides, it will be ignored.
FYI: you could still use Tailwind in CSS, with the @apply directive. You could just do normal CSS rules as well for fine-grained control, or mix and match both to your taste.
Future plans
I'm using this blog as a soft re-introduction to full-stack web development, so this is only the beginning to my journey. I will be posting more #web-development content in the future, so stay tuned for updates!
Planned features
Below are some features potentially sitting on my to-do list, ranked in priority based on ease of implementation, scope and usefulness.
- Similar article suggestion.
- Comments, likes and share count tracker.
- RSS feed integration.
If you have an idea of a feature you'd like to see, email me!
Footnotes
-
I'm aware of the ongoing debate of web terminology "library" vs "framework". I'm not too picky with the terms myself, so you might see me using both interchangeably. ↩
-
With RSC, static HTML content is generated on the server, which means search engine crawlers can more easily index its content and pick out keywords matching the search query better. ↩
-
This metric evaluates how severely the page layout changes as a new piece of content loads in. Search engines factor this into its relevancy ranking algorithm, though the actual weighting remains a point of debate amongst developers. ↩
-
This refers to routes that don't use Dynamic APIs. ↩
-
If you've done multiplayer networking before, you would be no stranger to this phenomenon. Desynchronization over the network can have a number of causes, the leading cause being high latency or packet loss caused by an unstable connection. ↩
-
According to the NextJS documentations, there is currently no way to stream non-serializable data across the network in the first page load. The client would have to initiate a second request for this data afterwards, in a typical
useEffectclient-side fetch implementation. ↩ -
ChatGPT very confidently tried to get me to add an
index.tsxto my App Router, multiple times, even after I've corrected it. ↩