Building a Blog with Next.js + Tailwindcss

February 21, 2021

This website is built using Next.js and Tailwindcss and it's been a blast. I want to write down what I've learned and what I did. So in this article, we're gonna build a simple blog website. This website will allow user to write articles in Markdown.

Next.js is a popular React framework and it supports Static Generation which is perfect for our use case. For those who don't know, Static Generation is a process to generate the static website at build time. Normally, a traditional website has to be rebuilt for every visit. Static websites are built before they go live, and so instead of building 1 million times for 1 million visitors, they build once. This website will then be reused on each request and can be cached by a CDN for performance.

Project Setup

This is the environment we use in this guide:

> node -v

> npm -v

You can install Node.js from here. I'm using fnm to manage my node.js environment.

Create a new Next.js project

Let's create a new Next.js project using create-next-app and start the dev server:

> npx create-next-app nextjs-blog
> cd nextjs-blog
# start the dev server
> npm run dev

Open http://localhost:3000 in your browser, you will see this default Next.js info page:

Home page

Install all required packages

Now we have a new Next.js project running, but we still need to install some packages to ease our job on building this website. Run this command in terminal:

> npm install -D gray-matter uniqid remark remark-html tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/typography dayjs

Let me explain why we need each of them:

  • gray-matter: converts a string with YAML front-matter into an object so we can use the value in JavaScript


title: Hello
slug: home

<h1>Hello world!</h1>


  content: '<h1>Hello world!</h1>',
  data: {
    title: 'Hello',
    slug: 'home'
  • uniqid: to generate unique ID for our list because React requires unique key for every elements in a list
  • remark: to process our Markdown content
  • remark-html: to serialize Markdown as HTML
  • tailwindcss: to style our website
  • postcss: required by tailwindcss
  • autoprefixer: required by tailwindcss
  • tailwindcss/typography: to style the HTML elements generated from our Markdown files
  • dayjs: to format the date value

Config Tailwindcss

Next, we have to generate tailwind.config.js and postcss.config.js files in our project:

npx tailwindcss init -p

Open the ./styles/global.css file in the project, use the @tailwind directive to include Tailwind's base, components, and utilities styles, replacing the original file contents:

@tailwind base;
@tailwind components;
@tailwind utilities;

Open ./tailwindcss.config.js file, add @tailwindcss/typography to the plugins list:

module.exports = {
  variants: {
    extend: {},
-  plugins: [],
+  plugins: [require('@tailwindcss/typography')],

Start the server again to make sure everything is still working.

Project route

The website we're going to build will have these pages:

  • /blog: the blog posts list
  • /blog/hello-world: the Hello World article
  • /blog/foo-bar: the Foo Bar article

Let's see how we can do this. Next.js has a file-system based router. When a file is added to the ./pages directory it's automatically available as a route:

  • ./pages/index.js -> /
  • ./pages/blog/index.js -> /blog
  • ./pages/blog/analytics.js -> /blog/analytics

Nobody wants to write a new page, e.g. ./pages/blog/hello-world.js, whenever there is a new article. Therefore, we need a more scalable way to handle this. As we know every article page logic is the same, to display the content from the .md file, we can use Next.js Dynamic Routes to handle the same route pattern with the same file:

  • ./pages/blog/[slug].js -> will match /blog/1, /blog/hello-world, /blog/foo-bar

Let's create both files:

# create blog folder under pages folder
> mkdir pages/blog
# create index.js file and [slug].js file under blog folder
> touch pages/blog/index.js pages/blog/\[slug\].js

We also need a folder to store all our Markdown files (.md). Let's create a ./posts folder and few articles:

# create posts folder
> mkdir posts
# create 2 markdown files for testing purpose
> touch posts/ posts/

Open ./posts/ and add:

title: "Hello World"
slug: hello-world
date: "2021-02-19"

> This is my **Hello World** post.

Same with ./posts/

title: "Foo Bar"
slug: foo-bar
date: "2021-02-20"

This is my `Foo Bar` post.

function foo() {
  return "bar";

Blog Engine

Now we can start writing our blog engine. The purpose of the blog engine is to read all .md files in ./posts folder and generate static HTML at build time.

Blog Posts List page

Let's go to ./pages/blog/index.js and start to create the function to display the blog posts list:

import Link from "next/link";
import dayjs from "dayjs";

export default function Blog({ posts }) {
  return (
    <div className="container mx-auto mt-16 w-6/12 divide-y-2">
      <h1 className="font-bold text-4xl mb-3">Articles</h1>
      <ul className="pt-5">
        { => (
          <li className="mb-3" key={}>
            <Link href={`/blog/${post.slug}`}>
              <a className="font-semibold text-2xl hover:text-red-500">
            <p className="text-sm text-gray-400">
              {dayjs("MMMM D, YYYY")}

// this function will get called at build time
export async function getStaticProps() {
  const fs = require("fs");
  const matter = require("gray-matter");
  const uniqid = require("uniqid");

  const postsDir = `${process.cwd()}/posts`;

  const files = fs.readdirSync(postsDir, "utf-8");
  const posts = files
    .filter((file) => file.endsWith(".md"))
    .map((file) => {
      const rawContent = fs.readFileSync(`${postsDir}/${file}`, {
        encoding: "utf8",
      const { data } = matter(rawContent);

      return {, id: uniqid() };
    .sort((a, b) => new Date( - new Date(;

  return {
    props: { posts },

The getStaticProps function will get called at build time. In the function we are reading all the .md files and sort them in descending order by the date value so the newest post is showed at the top. The Blog component will receive the posts as props because we return them in getStaicProps function.

Start the dev server and open http://localhost:3000/blog, you can now see this page show all our articles:

Blog page

Article Page

In order to display our articles on the correct page accordingly, we need to read the .md file based on the slug value from the URL, and also generate different HTML for each page.

  • /blog/hello-world: the slug value is hello-world, so it should display the article from ./posts/
  • /blog/foo-bar: the slug value is foo-bar, so it should display the article from ./posts/

Let's write the logic in ./posts/[slug].js:

import dayjs from "dayjs";

export default function BlogPostPage(props) {
  const { title, content, date } =;

  return (
    <div className="container mx-auto mt-16 w-6/12">
      <h1 className="font-bold text-4xl mb-3">{title}</h1>
      <p className="text-sm text-gray-400">
        {dayjs(date).format("MMMM D, YYYY")}
      <section className="mt-5" dangerouslySetInnerHTML={{ __html: content }} />

// this function will get called at build time
export async function getStaticProps(context) {
  const fs = require("fs");
  const remark = require("remark");
  const html = require("remark-html");
  const matter = require("gray-matter");

  const postsDir = `${process.cwd()}/posts`;

  const { slug } = context.params;
  const rawContent = fs.readFileSync(`${postsDir}/${slug}.md`);
  const { data, content } = matter(rawContent);

  const result = await remark().use(html).process(content);

  return {
    props: {
      post: {,
        content: result.toString(),

// this function will get called at build time
export async function getStaticPaths(context) {
  const fs = require("fs");
  const files = fs.readdirSync(`${process.cwd()}/posts`, "utf-8");

  const filenames = files
    .filter((file) => file.endsWith(".md"))
    .map((file) => file.replace(".md", ""));

  return {
    paths: => ({
      params: {
        slug: filename,
    fallback: false,

In getStaticProps function, we use remark and remark-html to parse .md file content into HTML then pass to BlogPostPage component as props.

We also use another function provided by Next.js called getStaticPaths. When we export an async getStaticPaths function from a dynamic page (./pages/blog/[slug].js in this case), this function will get called at build time and generate the HTML file accordingly.

Let's check the file generated from our codes so far. Add next export command in package.json file:

  "scripts": {
    "dev": "next dev",
-    "build": "next build",
+    "build": "next build && next export",
    "start": "next start"

then run in terminal:

> npm run build

These generated files are in the out folder including foo-bar.html and hello-world.html because of the getStaticPaths function:

File output

Run the server and open http://localhost:3000/blog/hello-world. We can see the content of the article now:

Hello World Page

Style the Generated HTML

As you may have noticed that our content is not styled properly, e.g. the <backquote> in this case. Tailwind provides a very easy way to automatically style the HTML elements for us using Tailwind Typography.

All we have to do is to add prose class in ./posts/[slug].js:

// ...
export default function BlogPostPage(props) {
    const { title, content, date } =

    return (
        <div className="container mx-auto mt-16 w-6/12">
            <h1 className="font-bold text-4xl mb-3">{title}</h1>
            <p className="text-sm text-gray-400">{dayjs(date).format('MMMM D, YYYY')}</p>
-            <section className="mt-5" dangerouslySetInnerHTML={{__html: content}} />
+            <section className="mt-5 prose" dangerouslySetInnerHTML={{__html: content}} />
// ...

The <backquote> element now looks good in http://localhost:3000/blog/hello-world:

Hello World Page after styling

The code block in http://localhost:3000/blog/foo-bar is also styled nicely:

Foo Bar Page after styling

That completes this simple guide on building a blog with Next.js and Tailwindcss. Now you are ready to show the world what you've done by deploying this blog using Vercel or Netlify. Cheer!

You can check the source codes here.