build-a-blog-using-nextjs-a5
build-a-blog-using-nextjs-a5
Using Next.js
Diana MacDonald
Build a Blog Using Next.js
Diana MacDonald
Queensland, Australia
https://didoesdigital.com/project/nextjs-blog-book/
This work is subject to copyright. All rights are reserved by the Publisher,
whether the whole or part of the material is concerned, specifically the
rights of translation, reprinting, reuse of illustrations, recitation,
broadcasting, reproduction on microfilms or in any other physical way,
and transmission or information storage and retrieval, electronic
adaptation, computer software, or by similar or dissimilar methodology
now known or hereafter developed.
While the advice and information in this book are believed to be true and
accurate at the date of publication, neither the authors nor the editors nor
the publisher can accept any legal responsibility for any errors or
omissions that may be made. The publisher makes no warranty, express or
implied, with respect to the material contained herein.
Page 1
The companion repository of source code for this book is available to
readers on GitHub via the book’s product page, located at https://
didoesdigital.com/project/nextjs-blog-book/
Page 2
Table of Contents
• Introduction
• Chapter 1: Set Up a Next.js Site and VS Code
• Chapter 2: MDX and Metadata
• Chapter 3: Site Styling
• Chapter 4: Markdown Styling
• Chapter 5: Next.js Images
• Chapter 6: Blog Metadata and Navigation
• Chapter 7: Syntax Highlighting
• Chapter 8: Heading IDs and Links
• Chapter 9: RSS
• Chapter 10: Favicons and Dark Mode
• Chapter 11: Google Analytics and Sitemap
• Chapter 12: Robots file, 404s, and Open Graph images
• Conclusion: Production
Page 3
Introduction
This book describes how to build a Next.js blog in
2024 using all the latest practices, and a static
export. It includes using local MDX files that let you
write posts with Markdown, JSX, and rich content.
I’ve included considerations for tech choices along
the way. There are caveats before getting started and
some editor setup guidance.
Page 4
If you find this book useful, please consider
supporting my work by paying for the book on
Ko‑fi: https://ko-fi.com/didoesdigital.
Page 5
sites don’t require an app server. I’ll assume you
have some familiarity with React and web
fundamentals.
Page 6
I had specific motivations to build my site and blog
in Next.js using React Server Components with lots
of customisation, but if I were looking for a
standard, statically generated site just for a blog, I’d
consider Eleventy or Astro or Zola or something
else.
Page 7
can be a great choice. If you just want a quick,
maintainable blog, you might want to consider an
alternative static site generator option.
Templates
We’re going to use the Create Next App Command
Line Interface (CLI) to kick us off but we’re not
going to use a template. Given how much you’d
need to build or rebuild to make a full blog using the
latest features, the templates don’t offer much.
Page 8
• The blog starter kit template includes
Markdown, but not MDX, which means
without further work it is tedious to include
rich content such as social media post embeds,
videos, syntax highlighted code blocks, or IDs
and links on headings.
• The blog starter template uses a custom
markdownToHtml pipeline using remark-html
instead of Next.js’s recommended @next/mdx
package and config. Similarly, it uses the gray-
matter package to parse front matter instead of
Next.js’s built-in metadata object. These
approaches have their trade-offs but seem to
grate against the built-in Next.js features. Other
templates parse YAML-style front-matter using
regex, which is wild.
• The blog starter template doesn’t include a
sitemap or RSS feed, or support for categories,
tags, or search.
Page 9
• The blog starter template has the occasional
stray artifact from older versions of Next.js,
such as the as attribute on Link components,
which is no longer required, and duplicate
public/ directories. There are also immediate
metabase console warnings.
Page 10
Conventions in this book
For code blocks, filenames are included in the
preceding paragraph.
Page 11
Troubleshooting
Be precise with directories and
files
Next.js relies on directory and filename
conventions. This is convenient for producing
desired behaviour by default, but can lead to
confusing errors in some situations. Keep in mind:
Page 12
• app/ and pages/ directories are special, so avoid
directories with those names except in the exact
places where Next.js expects them for routing,
which is discussed in the first chapter.
• There are special filenames that have specific
behaviour, such as page.tsx. They are
documented in Next.js’s file conventions. Only
use those names as intended by Next.js.
• Avoid whitespace in paths and filenames. Be
mindful when copying and pasting code from
the book or the repository.
• Avoid extraneous empty directories.
Page 13
Restart the dev server
For a lot of changes you make, the dev server will
auto-reload. Occasionally, however, some changes
won’t be picked up, such as changes to config files or
packages. If something you’ve changed isn’t
applying, try restarting the server.
Page 14
Import aliases using @
If you see errors related to resolving modules
containing an @ symbol, it may be related to path
aliases. Review your tsconfig.json file and your
file structure.
Expected paths
There are some parts of the system that expect files
in certain places. If you move files or directories
around, you may need to review configuration
paths, such as the content array in the Tailwind
config file, paths in the tsconfig.json file, or
scripts in the package.json. Until you’re
comfortable with how each part works, I
recommend sticking with the paths and filenames
I’ve used in the book.
Page 15
Next.js cache
Next.js is incredibly clever at efficiently rebuilding
your site when you make changes, but sometimes it
can get into a weird state. On rare occasions, you
may try removing the .next/cache directory and
restarting the server.
Page 16
Chapter 1: Set Up
a Next.js Site and
VS Code
Getting started
Create a new project directory using the
recommended create-next-app automatic
installation CLI. By supplying no arguments, it will
kick off an interactive setup process. If npx prompts
you to install packages, type y and Return . We’ll
choose Yes for TypeScript, ESLint, Tailwind CSS,
src/ directory, and App Router. We won’t
Page 17
default create-next-app options, type Return for
each prompt. Otherwise, use arrow keys to switch
between them.
$ npx [email protected]
✔ What is your project named? …
next14-static-export-mdx-blog
✔ Would you like to use TypeScript? …
No / Yes
✔ Would you like to use ESLint? …
No / Yes
✔ Would you like to use Tailwind CSS?
… No / Yes
✔ Would you like to use src/
directory? … No / Yes
✔ Would you like to use App Router?
(recommended) … No / Yes
✔ Would you like to customize the
default import alias (@/)? … No / Yes
Page 18
Change directory into your new project:
cd next14-static-export-mdx-blog
Page 19
The site at http://localhost:3000/
Page 20
Inspect the source code
Here’s how most of our folders and files look:
README.md
next-env.d.ts
next.config.mjs
node_modules/
package-lock.json
package.json
postcss.config.mjs
public/
└── next.svg
└── vercel.svg
src/
└── app/
└── favicon.ico
└── globals.css
└── layout.tsx
└── page.tsx
tailwind.config.ts
tsconfig.json
Page 21
App Router vs Page Router
Use App Router. From the Next.js Page Router docs:
Page 22
When searching for or within Next.js
documentation, make sure you’re looking at the
modern “App Router” version of the docs.
There is an on-page toggle to switch between
“App Router” and the older “Page Router”.
There are also tabs for “App Router” and “Page
Router” results in the Next.js documentation
search command menu. Outside of the Next.js
docs, there are lots of links to the older “Page
Router” so keep an eye out for changes to the
URL and on-page toggle.
Page 23
Editor config
TypeScript
According to the docs on Next.js TypeScript Plugin:
Page 24
You can enable the plugin in VS Code by:
{
"typescript.tsdk": "node_modules/
typescript/lib"
}
Page 25
You may or may not want to add this workspace
settings file to your .gitignore file. If you want to
enforce shared VS Code settings with others, you
can Git commit the .vscode/settings.json file
and any changes to it that should be shared.
Tailwind
Check out the docs on Tailwind editor setup.
Page 26
{
"typescript.tsdk": "node_modules/
typescript/lib",
"files.associations": {
"*.css": "tailwindcss",
"globals.css": "tailwindcss"
}
}
If you also use the Git Lens extension and it’s too
noisy, you can add:
Page 27
{
"typescript.tsdk": "node_modules/
typescript/lib",
"[tailwindcss]": {
"gitlens.codeLens.scopes":
["document"]
},
"files.associations": {
"*.css": "tailwindcss"
}
}
Page 28
There should be a lint script in the package.json
for running next lint:
{
"scripts": {
"lint": "next lint"
}
}
{
"devDependencies": {
"eslint": "^8",
"eslint-config-next": "14.2.2"
}
}
Page 29
There should be an .eslintrc.json file in the root
of the project to use the Next.js core web vitals
config. While you can specify the one "next/core-
web-vitals" configuration file here in extends as a
{
"extends": ["next/core-web-vitals"]
}
warnings or errors:
Page 30
$ npm run lint
> [email protected]
lint
> next lint
✔ No ESLint warnings or errors
Prettier
There are useful Prettier docs and Next.js Prettier
docs.
Page 31
Create an empty config file called .prettierrc in
the root of the project to let your editor and other
tools know you’re using Prettier:
{}
public/
package-lock.json
Page 32
npm install --save-dev eslint-config-
prettier
{
"extends": ["next/core-web-vitals",
"prettier"]
}
Page 33
Otherwise, you can add a script to package.json,
e.g.:
{
"scripts": {
"format": "npx prettier --write
'src/**/*.{js,ts,jsx,tsx,mdx}' --log-
level 'warn'"
}
}
Page 34
Install the plugin (and prettier if you haven’t
already):
{
"plugins": ["prettier-plugin-
tailwindcss"]
}
Page 35
Static exports
For a simple deployment of static HTML/CSS/
JavaScript files, we can use Next.js’s support for
static exports. Skim the docs to see which Next.js
features are supported and which are not. Using a
static export means you don’t need to run a node
server, which can simplify deployment and some
security considerations. One drawback, however, is
that Next.js docs don’t always cover how you need
to adjust your code to work with static exports.
Page 36
/** @type {import('next').NextConfig}
*/
const nextConfig = {
output: "export",
};
package.json script:
Page 37
npx http-server ./out/
You should then see exactly the same blog when you
visit http://localhost:8080/.
Page 38
Chapter 2: MDX
and Metadata
Add MDX
MDX lets you use JSX in your Markdown content.
We can follow the Next.js MDX docs to add MDX
to our blog using @next/mdx.
Page 39
Install the necessary dependencies and types:
Page 40
import createMDX from "@next/mdx";
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {
output: "export",
pageExtensions: ["js", "jsx", "md",
"mdx", "ts", "tsx"],
};
Page 41
import type { MDXComponents } from
"mdx/types";
export function
useMDXComponents(components:
MDXComponents): MDXComponents {
return {
...components,
};
}
messages/context-in-server-component
Page 42
It isn’t terribly obvious from the message that the
answer is to add the missing mdx-components.tsx
next to the app/ directory. Since our app/ directory
is in src/app/, we need our file to be at src/mdx-
components.tsx.
Page 43
# First MDX post
Hello world!
Page 44
type BlogPageProps = { params: { slug:
string } };
Page 45
export async function
generateStaticParams() {
const blogPosts = ["first-mdx-
post"]; // FIXME: Read from file
system
const blogStaticParams =
blogPosts.map((post) => ({
slug: post,
}));
return blogStaticParams;
}
Page 46
src/
├── app/
│ ├── blog/
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── blog/
│ └── first-mdx-post.mdx
└── mdx-components.tsx
Now when you run npm run dev and visit http://
Page 47
It’s automatically using the root layout.tsx, which
means we can see an awful repeating gradient
background. Let’s fix that in globals.css:
Page 48
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-
rgb));
}
Page 49
We want to show the actual MDX post content
though. Let’s update src/app/blog/[slug]/
page.tsx to dynamically import the MDX content
Page 50
import dynamic from "next/dynamic";
//
return (
<div className="container mx-auto
p-4">
<h2 className="text-xl">{params.
slug}</h2>
<BlogMarkdown />
</div>
);
}
Page 51
If you see an error like Module not found:
Now when you run npm run dev and visit http://
Page 52
The first MDX post at http://localhost:3000/blog/
first-mdx-post
MDX metadata
We can use Next.js’s static metadata object to specify
the title and description of the MDX post.
Page 53
The metadata object sets the page’s title and meta
description in the HTML head.
Hello world!
Page 54
Let’s create some reusable functionality to import a
post’s metadata.
Page 55
import type { Metadata } from "next/
types";
Page 56
import { notFound } from "next/
navigation";
if (file?.metadata) {
if (!file.metadata.title || !
file.metadata.description) {
throw new Error(`Missing some
required metadata fields in: ${slug}
`);
}
return {
slug,
metadata: file.metadata,
};
} else {
throw new Error(`Unable to find
metadata for ${slug}.mdx`);
}
Page 57
} catch (error: any) {
console.error(error?.message);
return notFound();
}
}
Update src/app/blog/[slug]/page.tsx to
generate dynamic metadata from the MDX file:
Page 58
import { getBlogPostMetadata } from
"@/app/blog/_lib/getBlogPostData";
import type { Metadata } from "next/
types";
//
if (metadata) {
return metadata;
} else {
throw new
Error(`No metadata found for blog
post: ${params.slug}`);
}
}
Page 59
Now when you run npm run dev, you should see
Page 60
export default async function
BlogPage({ params }: BlogPageProps) {
const { metadata } = await
getBlogPostMetadata(params.slug);
const title = `${metadata.title ??
""}`;
return (
<div className="container mx-auto
p-4">
<h2 className="my-4 text-center
text-xl font-bold">{title}</h2>
<BlogMarkdown />
</div>
);
}
Page 61
We use Next.js’s dynamic import to lazy load the
metadata. We’ve made the function async so that
we can await the metadata. We’ve used the
metadata title in a styled heading and removed the
slug from the page.
Page 62
Chapter 3: Site
Styling
Fonts
Let’s add some fonts. Check out the Next.js fonts
docs.
file:
Page 63
import { Crimson_Text, Overpass_Mono,
Work_Sans } from "next/font/google";
Page 64
display: "swap",
variable: "--font-overpass-mono",
});
Page 65
import { crimson, workSans,
overpassMono } from "@/app/
_components/fonts/fonts";
// …
<html
lang="en"
className={`${crimson.variable} ${wo
rkSans.variable} ${overpassMono.variab
le}`}
>
<body>{children}</body>
</html>
Page 66
Our 3 custom font family names are available as
CSS variables
Page 67
const config: Config = {
theme: {
fontFamily: {
sans: [
"var(--font-work-sans)",
"ui-sans-serif",
"system-ui",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
"sans-serif",
],
serif: [
"var(--font-crimson)",
"ui-serif",
"Georgia",
"Cambria",
"Times New Roman",
"Times",
"serif",
],
mono: [
"var(--font-overpass-mono)",
"ui-monospace",
"SFMono-Regular",
Page 68
"Menlo",
"Monaco",
"Consolas",
"Liberation Mono",
"Courier New",
"monospace",
],
},
//
},
//
};
settings:
Page 69
// tailwind.config.ts
const config: Config = {
theme: {
extend: {
fontFamily: {
serif: [
"var(--font-crimson)",
"ui-serif",
"Georgia",
"Cambria",
"Times New Roman",
"Times",
"serif",
],
//
},
//
},
},
//
};
Page 70
We’ll stick with the first option. Apart from our
custom font CSS variables, the rest of the font
family names are the same as the default Tailwind
settings.
Page 71
Our fonts are now available to use and Tailwind has
automatically applied the sans-serif font to the html
element.
Typography
The Tailwind docs on adding base styles suggest
adding global colors and font styles like this:
Page 72
<html
lang="en"
className={`${crimson.variable} $
{workSans.variable} $
{overpassMono.variable} font-serif`}
>
<body>{children}</body>
</html>
Page 73
When we want to apply a sans serif font to a
button, we can add the font-sans class to the
button. When we want to apply the monospace font
to a code block, we can add the font-mono class to
the code block. We can also use our CSS variables
directly in CSS.
Colors
To configure our own brand colors, we could replace
the Tailwind colors directly with our colors in the
tailwind.config.ts file like this:
Page 74
theme: {
colors: {
black: "#ffffff",
blue: {
"50": "#f0f9ff",
"100": "#e1f3fd",
"200": "#bbe8fc",
"300": "#80d6f9",
"400": "#3cc1f4",
"500": "#13a9e4",
"600": "#0689c3",
"700": "#066d9e",
"800": "#0a5c82",
"900": "#0e4c6c",
"950": "#0b3954",
}
}
}
Page 75
First, update the globals.css with color CSS
variables for your brand colors (and move these
base styles into the base layer before utilities):
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-blue-50: #f0f9ff;
--color-blue-100: #e1f3fd;
--color-blue-200: #bbe8fc;
--color-blue-300: #80d6f9;
--color-blue-400: #3cc1f4;
--color-blue-500: #13a9e4;
--color-blue-600: #0689c3;
--color-blue-700: #066d9e;
--color-blue-800: #0a5c82;
--color-blue-900: #0e4c6c;
--color-blue-950: #0b3954;
--color-teal-50: #eefffc;
--color-teal-100: #c5fffa;
--color-teal-200: #8bfff5;
Page 76
--color-teal-300: #4afef0;
--color-teal-400: #15ece2;
--color-teal-500: #00d0c9;
--color-teal-600: #00a8a5;
--color-teal-700: #008080;
--color-teal-800: #066769;
--color-teal-900: #0a5757;
--color-teal-950: #003235;
--color-neutral-50: #f6f6f6;
--color-neutral-100: #e7e7e7;
--color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0;
--color-neutral-400: #888888;
--color-neutral-500: #737373;
--color-neutral-600: #5d5d5d;
--color-neutral-700: #4f4f4f;
--color-neutral-800: #454545;
--color-neutral-900: #3d3d3d;
--color-neutral-950: #262626;
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}
Page 77
--foreground-rgb: 255, 255, 255;
}
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-
rgb));
}
}
@layer utilities {
/* */
}
theme: {
colors: {
transparent: "transparent",
current: "currentColor",
black: "#000000",
Page 78
white: "#ffffff",
blue: {
50: "var(--color-blue-50)",
100: "var(--color-blue-100)",
200: "var(--color-blue-200)",
300: "var(--color-blue-300)",
400: "var(--color-blue-400)",
500: "var(--color-blue-500)",
600: "var(--color-blue-600)",
700: "var(--color-blue-700)",
800: "var(--color-blue-800)",
900: "var(--color-blue-900)",
950: "var(--color-blue-950)",
},
teal: {
50: "var(--color-teal-50)",
100: "var(--color-teal-100)",
200: "var(--color-teal-200)",
300: "var(--color-teal-300)",
400: "var(--color-teal-400)",
500: "var(--color-teal-500)",
600: "var(--color-teal-600)",
700: "var(--color-teal-700)",
800: "var(--color-teal-800)",
900: "var(--color-teal-900)",
950: "var(--color-teal-950)",
},
Page 79
neutral: {
50: "var(--color-neutral-50)",
100: "var(--color-neutral-100)",
200: "var(--color-neutral-200)",
300: "var(--color-neutral-300)",
400: "var(--color-neutral-400)",
500: "var(--color-neutral-500)",
600: "var(--color-neutral-600)",
700: "var(--color-neutral-700)",
800: "var(--color-neutral-800)",
900: "var(--color-neutral-900)",
950: "var(--color-neutral-950)",
}
}
}
Page 80
A teal heading
Page 81
/* src/app/globals.css */
@layer base {
a[href] {
text-decoration-style: wavy;
}
}
Page 82
Chapter 4:
Markdown
Styling
Styling Markdown
approaches
We’re going to cover multiple approaches to styling
Markdown content in Next.js that can be used
separately or together:
Page 83
• Defining components for elements in the MDX
content and applying Tailwind classes to them
• Using CSS modules to style elements in the
MDX content
• Using the Tailwind typography plugin
Markdown components
Our MDX content is unstyled. Let’s set up some
components in the src/mdx-components.tsx file to
add Tailwind classes to heading elements:
Page 84
export function
useMDXComponents(components:
MDXComponents): MDXComponents {
return {
h2: ({ children, ...props }) => (
<h2 className="text-lg font-
semibold" {...props}>
{children}
</h2>
),
...components,
};
}
h2 heading.
Page 85
After manually refreshing the page, you should then
see the Tailwind classes present on the heading
element:
Page 86
Generate classes for all Tailwind
content
Tailwind won’t generate more styles than it needs to
and it relies on your configuration to know which
classes to generate. To make sure Tailwind class
names you use in MDX content get styles generated,
add the blog and mdx-components.tsx paths to the
content array in the tailwind.config.ts file:
Page 87
Now Tailwind classes found in these files will have
generated styles.
Page 88
Then update the page component to import the
CSS module and apply the “markdown” class to the
container div:
Page 89
import markdownStyles from "@/app/
blog/_components/markdown/
markdown.module.css";
//
return (
Page 90
## A h2 heading
Page 91
Tailwind typography plugin
The @tailwindcss/typography plugin for Tailwind
is designed to make prose like a blog post look good,
with minimal effort, in a Tailwind way. The post
Introducing Tailwind CSS Typography elaborates
on how it fits into Tailwind’s model and the
Tailwind typography repo includes the docs.
Update tailwind.config.ts:
Page 92
const config: Config = {
/* */
plugins: [require("@tailwindcss/
typography")],
};
look better:
Page 93
export default async function
BlogPage({ params }: BlogPageProps) {
//
return (
<div
className={`prose lg:prose-xl da
rk:prose-invert container mx-auto p-4
${markdownStyles["markdown"]}`}
>
<h1 className="my-4 text-center
text-teal-800">{title}</h1>
<BlogMarkdown />
</div>
);
}
Page 94
import type { MDXComponents } from
"mdx/types";
export function
useMDXComponents(components:
MDXComponents): MDXComponents {
return {
...components,
};
}
Page 95
Headings styled thanks to .prose
Page 96
Chapter 5:
Next.js Images
Add images
Let’s add an image. We’ll explore some approaches
and challenges with images in MDX and static
exports before we get to a solution. You should
expect to see some errors along the way, which I’ll
include and cover how to address each one.
Page 97

<Image
src="/assets/images/curlew.jpg"
alt="A cryptic motionless bush
stone-curlew snuggled into wood chips"
/>
Page 98
With the Image component you might get a funky
error like:
Page 99
import Image from "next/image";
import type { ImageProps } from "next/
image";
this:
Page 100
import Figure from "@/app/blog/
_components/Figure";
<Figure
src="/assets/images/curlew.jpg"
alt="A cryptic motionless bush
stone-curlew snuggled into wood chips"
/>
Page 101
import Figure from "@/app/blog/
_components/Figure";
import type { MDXComponents } from
"mdx/types";
export function
useMDXComponents(components:
MDXComponents): MDXComponents {
return {
Figure: (props) => <Figure
{...props} />,
...components,
};
}
Page 102
export const metadata = {
title: "My first MDX blog post",
description: "A short MDX blog
post.",
};
## A h2 heading
<Figure
src="/assets/images/curlew.jpg"
alt="A cryptic motionless bush
stone-curlew snuggled into wood chips"
width={1600}
height={900}
/>
Page 103
Error: Image Optimization using the default
loader is not compatible with { output:
Page 104
Next.js Image Optimization doesn’t work out of the
box with static exports. We can turn it off by
amending next.config.mjs to include images: {
unoptimized: true }:
const nextConfig = {
output: "export",
images: {
unoptimized: true,
},
// …
};
Page 105
Our post has inline images
Page 106
To actually optimise images with a static
export, you can define a custom image loader.
That would let you use a service like
Cloudinary for image optimisation.
Alternatively, you could optimise images at
build time using a package like next-
optimized-images.
Page 107
Chapter 6: Blog
Metadata and
Navigation
Blog index listing page
Let’s create a blog index listing page that shows a list
of all of your blog posts.
Page 108
import type { Dirent } from "fs";
Page 109
try {
const dirents = await readdir("./
src/blog/", {
withFileTypes: true,
});
const slugs =
dirents.filter(isMDXFile).map(getSlugFromFilename
return result;
} catch (error) {
console.error(error);
return [];
}
}
Page 110
After that we can create a new file, src/app/blog/
page.tsx, for the blog index listing page to use our
Page 111
import Link from "next/link";
import { getAllBlogPostsData } from
"@/app/blog/_lib/getAllBlogPostsData";
return (
<div className="prose prose-xl
dark:prose-invert container mx-auto
px-4">
<h1 className="my-4 text-center
text-teal-800">Blog</h1>
<p>Here are some recent posts.</
p>
<ul>
{blogs.map(({ slug, metadata:
{ title } }) => (
<li key={slug}>
<p>
<Link prefetch={false} h
ref={`/blog/${slug}`}>
{`${title}`}
</Link>
Page 112
</p>
</li>
))}
</ul>
</div>
);
}
Page 113
Now that we have a new getAllBlogPostsData
function, we can use it to generate the static paths
for the blog posts in src/app/blog/[slug]/
page.tsx and fix our previously hard-coded blog
return blogStaticParams;
}
Page 114
Now you can make as many posts as you like.
Navigation
Readers need to move about. Add a header
containing nav to the existing root src/app/
layout.tsx file inside the <body>:
Page 115
>
<body>
<header>
<nav className="container
mx-auto mt-12 flex max-w-screen-lg
flex-wrap justify-between gap-y-2
px-5">
<div className="prose
prose-xl dark:prose-invert">
<Link href="/"
className="text-2xl font-semibold
tracking-wide">
My site name
</Link>
</div>
<div className="prose
prose-xl dark:prose-invert flex flex-
wrap gap-x-4 gap-y-0">
<p>
<Link className="font-
sans tracking-wide" href="/blog">
Blog
</Link>
</p>
</div>
</nav>
</header>
Page 116
{children}
</body>
</html>
);
}
Navigation
Page 117
Metadata title template
Now that we have multiple pages we can navigate
between, we should fix up the metadata.
Page 118
export const metadata: Metadata = {
title: {
template: `%s | My site name`,
default: "My site name",
},
description: "My site is about…",
authors: [{ name: "My name" }],
openGraph: {
locale: "en",
type: "website",
},
};
Page 119
import { Metadata } from "next";
Page 120
Now the blog index page will have the title “Blog |
My site name” and the individual blog post pages
will have a title like “My first MDX blog post | Blog
| My site name”.
Page 121
Chapter 7:
Syntax
Highlighting
Add syntax highlighting
Using syntax highlighting in code blocks can make
them easier to scan and read.
Page 122
purposes, we can use an option that runs at build
time. To do this, we can add MDX plugins to our
Next.js config.
Page 123
Here are 3 popular choices for syntax highlighting
HTML:
• rehype-highlight
• rehype-prism-plus
• rehype-pretty-code
Page 124
Add rehype-highlight
Install the package:
Page 125
import createMDX from "@next/mdx";
import rehypeHighlight from "rehype-
highlight";
//
Page 126
The hljs class is applied and the code block has no
syntax highlighting yet.
Page 127
Let’s choose a highlight.js theme. You can find a
list of themes on the highlight.js website. Set the
language dropdown to “Common” to see examples
just for the default included languages.
Page 128
The themes that have an accessible contrast ratio for
text of 4.5:1 or greater are:
• a11y-dark.css: 7.1
• a11y-light.css: 4.5
• devibeans.css: 5.1
• gml.css: 4.7
• ir-black.css: 5
• qtcreator-dark.css: 6.7
• stackoverflow-dark.css: 5.5
• stackoverflow-light.css: 4.5
• sunburst.css: 4.8
• tomorrow-night-bright.css: 5
Page 129
import "highlight.js/styles/
stackoverflow-dark.css";
tailwind.config.ts:
Page 130
typography: () => ({
DEFAULT: {
css: {
pre: {
paddingTop: 0,
paddingInlineEnd: 0,
paddingBottom: 0,
paddingInlineStart: 0,
},
},
},
xl: {
css: {
pre: {
paddingTop: 0,
paddingInlineEnd: 0,
paddingBottom: 0,
paddingInlineStart: 0,
},
},
},
}),
},
},
plugins: [require("@tailwindcss/
typography")],
};
Page 131
If you want to design your own highlight.js theme,
you can make a small CSS file with the right classes
for the stylable scopes. For example:
.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
.hljs,
.hljs-subst {
color: #fff;
}
.hljs-comment {
color: var(--color-neutral-700,
#4f4f4f);
}
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
Page 132
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
color: var(--color-blue-400,
#3cc1f4);
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
color: var(--color-teal-400,
#15ece2);
}
You can then import that CSS file at the top of the
src/app/blog/[slug]/page.tsx file:
import "./highlightjs-example.css";
Page 133
The CSS code block now has custom syntax
highlighting
an array of arrays:
Page 134
const withMDX = createMDX({
options: {
rehypePlugins: [[rehypeHighlight,
{ aliases: { markdown: "mdx" } }]],
},
});
Page 135
Chapter 8:
Heading IDs and
Links
Add IDs to headings
As a reader or an author, it can be useful to link to
specific sections in a post, which requires headings
to have IDs. We can use rehype-slug to
automatically add IDs to headings.
Page 136
In next.config.mjs, import the plugin package and
add the plugin to the array of rehypePlugins:
//
Run npm run dev and you should now see IDs
Page 137
The heading now has an automatic ID attribute
Page 138
Add links to headings
Once your headings have IDs, you can use rehype-
autolink-headings to create links on headings so
Page 139
import rehypeAutolinkHeadings from
"rehype-autolink-headings";
//
Page 140
The heading now contains an automatic link
Page 141
const withMDX = createMDX({
options: {
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "wrap",
properties: {
className: "linked-
heading",
},
},
],
[rehypeHighlight, { aliases: {
markdown: "mdx" } }],
],
},
});
Page 142
Now it should be possible to click the heading text
to navigate to that section. Once clicked, the URL
should update to include the heading ID as the
URL’s fragment e.g. http://localhost:3000/
blog/first-mdx-post#a-h2-heading.
Page 143
There are a few ways we could improve that. Let’s
tweak the tailwind.config.ts:
Page 144
":is(h1, h2, h3, h4, h5)
a": {
"font-weight":
"inherit",
"text-decoration":
"inherit",
},
//
},
},
}),
},
},
//
};
Page 145
the “target element” (with an id matching the URL’s
fragment), we can also display the # symbol to show
that it is the selected section. We can achieve both
of these styling goals using the :is() pseudo-class
function and the ::before pseudo-element in the
src/app/blog/_components/markdown/
markdown.module.css file:
Page 146
Now when we hover over a heading link, the #
symbol should appear before the heading text.
When we click the heading link and navigate to
http://localhost:3000/blog/first-mdx-post#a-
Page 147
Now you and your readers can easily access links to
specific sections to share with other people.
Page 148
Chapter 9: RSS
Add RSS
RSS (RDF Site Summary or Really Simple
Syndication) is a web feed that lets people subscribe
to updates from your site. People can follow your
blog using an RSS feed reader.
Page 149
Error: export const dynamic = "force-
advanced-features/static-html-export
Page 150
export async function GET() {
return new Response(
`<?xml version="1.0"
encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>Next.js Documentation</title>
<link>https://nextjs.org/docs</link>
<description>The React Framework for
the Web</description>
</channel>
</rss>`,
{
headers: {
"Content-Type": "text/xml",
},
},
);
}
Page 151
Whenever you find yourself writing a custom
route handler (route.ts) in Next.js, it’s a good
time consider custom route handler security
practices, such as any Cross-site Request
Forgery (CSRF) risks. This plain little RSS feed
example in our static site is safe enough because
we’re not handling sensitive data or connecting
to a database or anything like that, but it’s a
good habit to consider security when writing
custom route handlers.
Page 152
An unstyled XML RSS feed
Page 153
Install the package and commit the changes:
Page 154
In that same route handler, add a GET function that
returns the feed in RSS 2.0 format as a response:
Page 155
Now if you visit http://localhost:3000/blog/
rss.xml your browser might download the file
rss.xml/route.ts:
Page 156
export async function GET() {
const posts = await
getAllBlogPostsData();
posts.forEach((post) => {
feed.addItem({
title: `$
{post.metadata.title ?? ""}`,
link: `https://example.com/blog/
${post.slug}`,
description: `$
{post.metadata.description ?? ""}`,
date: new Date(), // TODO: set
this to the post's publish date
});
});
Page 157
This should fetch the blog post metadata and add
each post to the feed.
In src/app/blog/_lib/getBlogPostData.ts, we
can add a publishDate property to a
customMetadata object on our BlogPostData type
Page 158
export type PostMetadata = Metadata &
{
title: string;
description: string;
};
if (file?.metadata &&
file?.customMetadata) {
if (!file.metadata.title || !
Page 159
file.metadata.description) {
throw new Error(`Missing some
required metadata fields in: ${slug}
`);
}
if (!
file.customMetadata.publishDate) {
throw new Error(`Missing
required custom metadata field,
publishDate, in: ${slug}`);
}
return {
slug,
metadata: file.metadata,
customMetadata:
file.customMetadata,
};
//
}
//
}
}
Page 160
In our post, src/blog/first-mdx-post.mdx, add a
publish date using a JavaScript Date friendly format
in an exported customMetadata object:
Page 161
posts.forEach((post) => {
feed.addItem({
title: `${post.metadata.title ??
""}`,
link: `https://example.com/blog/$
{post.slug}`,
description: `$
{post.metadata.description ?? ""}`,
date: new
Date(post.customMetadata.publishDate),
});
});
Page 162
const result = await Promise.all(
slugs.map((slug) => {
return getBlogPostMetadata(slug);
}),
);
result.sort(
(a, b) =>
+new
Date(b.customMetadata.publishDate) -
+new
Date(a.customMetadata.publishDate),
);
Page 163
decides whether post a or post b should come first,
where b - a gives us descending order—newest
post first.
feed is generated.
If you’re happy with the results, you can add the RSS
feed to the metadata of your site. In src/app/
layout.tsx, you can add an alternates object to
Page 164
export const metadata: Metadata = {
//
alternates: {
types: {
"application/rss+xml": [
{
title: "My Blog RSS Feed",
url: "https://example.com/
blog/index.xml",
},
],
},
},
};
Page 165
Note: If you’re using trailingSlash: true in
Page 166
In a dynamic route like src/app/blog/[category]/
rss.xml/route.ts, we wouldn’t know at build time
Page 167
export async function
generateStaticParams() {
for (const category of categories) {
await generateRssFeed(category);
}
const result =
categories.map((category) => ({
category,
}));
return result;
}
Page 168
{
const categoryData =
getCategoryData(category);
Page 169
});
});
if (!(await stat(categoryRSSDir))) {
await mkdir(categoryRSSDir, {
recursive: true });
}
await writeFile(`${categoryRSSDir}/
rss.xml`, feed.rss2(), "utf-8");
}
Page 170
Chapter 10:
Favicons and
Dark Mode
Favicon
A favicon is a small icon representing your site that
can appear in a browser tab next to the page title, in
bookmarks, in browser history, or on desktops or
mobile home screens. You can use a custom favicon
to help your blog stand out.
Page 171
Next.js docs on favicons tells us that there are 2
ways to set icons in a Next.js app:
Page 172
the browser shows a favicon when looking at non-
HTML documents, such as RSS feeds or PDFs that
don’t have <link> tags to tell them where to find the
favicon file.
Page 173
public/
└── favicon/
└── android-chrome-192x192.png
└── android-chrome-256x256.png
└── apple-touch-icon.png
└── browserconfig.xml
└── favicon-16x16.png
└── favicon-32x32.png
└── favicon.ico
└── mstile-150x150.png
└── safari-pinned-tab.svg
└── site.webmanifest
└── favicon.ico
└── favicon.png
Page 174
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${crimson.variable}
${workSans.variable} ${overpassMono.va
riable} font-serif text-neutral-800`}
>
<head>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicon/apple-touch-
icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon/
favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
Page 175
href="/favicon/
favicon-16x16.png"
/>
<link rel="manifest" href="/
favicon/site.webmanifest" />
<link
rel="mask-icon"
href="/favicon/safari-
pinned-tab.svg"
color="#008080"
/>
<link rel="shortcut icon"
href="/favicon/favicon.ico" />
<meta name="msapplication-
TileColor" content="#000000" />
<meta
name="msapplication-config"
content="/favicon/
browserconfig.xml"
/>
<meta name="theme-color" conte
nt="#ffffff" />
</head>
<body>
<header>{/* */}</header>
{children}
</body>
Page 176
</html>
);
}
to "src": "/favicon/android-
chrome-192x192.png",.
Page 177
Dark mode
Dark mode is a feature to change the interface to a
dark color scheme. It is popular to provide light and
dark themes to let people read your blog the way
they like.
Page 178
<div
className={`prose lg:prose-xl
dark:prose-invert container mx-auto
p-4 ${markdownStyles["markdown"]}`}
>
<h1 className="my-4 text-center
text-teal-800">{title}</h1>
<BlogMarkdown />
</div>
Page 179
<html
lang="en"
className={`${crimson.variable} $
{workSans.variable} $
{overpassMono.variable} bg-white font-
serif text-neutral-800 dark:bg-
neutral-950`}
>
{/* */}
</html>
Page 180
@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
}
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-
rgb));
}
Page 181
import type { Viewport } from "next";
//
Page 182
Elements Tab of the Web Inspector. You can also set
dark mode for macOS and change colors to dark for
Windows.
Page 183
If you want to give readers the ability to switch
between light and dark mode on your site, check out
the Tailwind docs on Toggling dark mode manually.
The first pass isn’t too bad but let’s update the
heading color. We could update the text-teal-800
class to use text-teal-800 dark:text-teal-200
Page 184
PluginAPI) => ({
DEFAULT: {
css: {
"--tw-prose-headings":
theme("colors.teal[800]"),
"--tw-prose-invert-
headings": theme("colors.teal[200]"),
//
},
},
//
}),
},
},
plugins: [require("@tailwindcss/
typography")],
};
Page 185
A vibrant teal heading without classes
Page 186
Chapter 11:
Google Analytics
and Sitemap
Add Google Analytics
Adding web analytics to your site can be useful to
help you understand reader behaviour and improve
their experience.
Page 187
First, we’ll look at how to use Google Analytics
without Google Tag Manager.
Page 188
import { GoogleAnalytics } from
"@next/third-parties/google";
//
Page 189
Replace G-TODO with your Google Analytics ID.
Page 190
Add a sitemap
A sitemap lets you tell web crawlers about new
pages on your site and how often they are updated.
This can help some search engines index your site
more effectively and share your latest posts.
Page 191
import { MetadataRoute } from "next";
Page 192
import { getAllBlogPostsData } from
"@/app/blog/_lib/getAllBlogPostsData";
const newestBlogDate =
posts[0].customMetadata.publishDate;
const blogIndexEntry:
MetadataRoute.Sitemap[0] = {
url: `https://example.com/blog/
index.html`,
lastModified: newestBlogDate,
Page 193
changeFrequency: "weekly",
priority: 0.5,
};
const result =
[blogIndexEntry, ...blogPostEntries];
return result;
}
//
//
Page 194
return [
{
url: `https://example.com/
index.html`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 1,
},
...blogsSitemap,
];
}
Page 195
Error: Page "/sitemap.xml/
[[...__metadata_id__]]/route" is missing
exported function
"generateStaticParams()", which is
const nextConfig = {
// output: "export",
output: process.env.NODE_ENV ===
"production" ? "export" : undefined,
//
};
Page 196
If you tweak the config like that and visit http://
localhost:3000/sitemap.xml or run npm run
Page 197
Chapter 12:
Robots file, 404s,
and Open Graph
images
robots.txt
Page 198
User-Agent: *
Allow: /
Disallow: /private/
Sitemap: https://example.com/
sitemap.xml
Page 199
AI scrapers
If you want to block crawlers that train Large
Language Models (LLMs) with your content, you
can add User-Agent directives to block specific bots.
Here’s an example:
Sitemap: https://example.com/
sitemap.xml
User-agent: *
Disallow:
User-agent: AdsBot-Google
User-agent: Amazonbot
User-agent: Applebot
User-agent: AwarioRssBot
User-agent: AwarioSmartBot
User-agent: Bytespider
User-agent: CCBot
User-agent: ChatGPT-User
User-agent: Claude-Web
User-agent: ClaudeBot
Page 200
User-agent: DataForSeoBot
User-agent: FacebookBot
User-agent: FriendlyCrawler
User-agent: GPTBot
User-agent: Google-Extended
User-agent: GoogleOther
User-agent: ImagesiftBot
User-agent: Meltwater
User-agent: PerplexityBot
User-agent: PiplBot
User-agent: Seekr
User-agent: YouBot
User-agent: anthropic-ai
User-agent: cohere-ai
User-agent: img2dataset
User-agent: magpie-crawler
User-agent: omgili
User-agent: omgilibot
User-agent: peer39_crawler
User-agent: peer39_crawler/1.0
Disallow: /
Page 201
Add a custom 404 page
and error pages
Whenever a reader gets lost on your site or bumps
into an error, we want to help them recover and
minimise the disruption.
Page 202
center justify-center text-center">
<div className="prose prose-xl
dark:prose-invert">
<h1 className="mr-4
text-4xl">Not found</h1>
<p className="text-md">
This page could not be
found. Go{" "}
<Link className="tracking-
wide text-teal-700" href="/">
Home
</Link>
</p>
</div>
</div>
);
}
Page 203
An unoriginal 404 page
"use client";
Page 204
error: Error & { digest?: string };
reset: () => void;
}) {
console.error(error);
return (
<html
lang="en"
className="bg-white font-serif
text-neutral-800 dark:bg-neutral-950"
>
<body>
<div className="prose
dark:prose-invert mx-auto flex min-h-
screen flex-col items-center justify-
center text-center">
<h1 className="mr-4 inline-
block pr-4 text-4xl font-semibold">
Sorry, something went
wrong.
</h1>
<div className="inline-
block">
<p className="text-md">
<button
className="font-
semibold text-teal-700"
Page 205
onClick={() =>
reset()}
>
Try again
</button>{" "}
or{" "}
<a className="tracking-
wide text-teal-700" href="/">
go home
</a>
.
</p>
</div>
</div>
</body>
</html>
);
}
Page 206
A bland error page
Page 207
Open graph images
Open graph images are used by social media
platforms to show a preview of a link.
Page 208
export const metadata: Metadata = {
//
metadataBase: new URL("https://
example.com"),
openGraph: {
images: [
{
url: "/assets/images/site-
open-graph.png", // Must be an
absolute URL or use metadataBase with
a relative URL
width: 1576,
height: 888,
alt: "A personal blog",
},
],
locale: "en",
type: "website",
},
};
Page 209
Note that we set a metadataBase in order to
conveniently use a relative path for the image. In
this case, the image is stored at public/assets/
images/site-open-graph.png.
Page 210
export const metadata: Metadata = {
title: {
template: `%s | Blog | My site
name`,
default: `Blog`,
},
description: "This blog is about…",
openGraph: {
images: [
{
url: "/assets/images/site-
open-graph.png", // Must be an
absolute URL or use metadataBase with
a relative URL
width: 1576,
height: 888,
alt: "A personal blog",
},
],
locale: "en",
type: "article",
},
};
Page 211
Check the <meta> tags in the <head> of your site to
see if the open graph image is set correctly:
Page 212
export const metadata = {
title: "My first MDX blog post",
description: "A short MDX blog
post.",
openGraph: {
images: [
{
url: "/assets/images/
curlew.jpg",
width: 1600,
height: 900,
alt: "A cryptic motionless
bush stone-curlew snuggled into wood
chips",
},
],
locale: "en",
type: "article",
},
};
Page 213
Conclusion:
Production
Production
When your site is nearly ready, review the Next.js
production checklist.
Spell check
Before sharing your posts with the world, use a spell
checker or even a fancier grammar checker. You can
use a tool like Grammarly or Hemingway. In your
editor, you can use a spell checker like Code Spell
Checker for VS Code.
Page 214
Lint and format
Once you’re satisfied, lint and format your code:
Build
Build your site and review it locally:
Page 215
Deployment
Given the static export, you can deploy your static
Next.js site on a variety of platforms.
Page 216
After deploying the static export of your site from
the out directory, you can visit your site on the
Internet and see your hard work in action. Check
that the 404 page is configured correctly and
subscribe to your RSS feed.
Celebrate!
Great work! You’ve built a blog with Next.js!
Page 217
Next steps
Using what you’ve learned, you can continue to
build out any new features that you like, such as an
About or Contact page. You could add client React
components to make your site and blog posts more
interactive. Maybe you’d like to link to your social
media profiles or add a newsletter subscription
form. You can style your site any way you like to
make it your very own.
Page 218