In this article, I'm going to create a statically rendered blog using Markdown and NEXT.JS 14. It will be a very simple blog, just a list of nicely formatted posts and article pages. You can see the final project here

All the css and finished files can be found here at my github repo feel free to download and do what you like with it, but a credit would be great if you decide to use it for your personal blog.

This article assumes you have basic knowledge of Next.JS such as routes and what the Link compnonent is.

Getting started

To get started lets install Next.JS and all our required dependancies. I'm calling this project technoy, but feel free to call it whatever you like.

In your terminal (i'm using the built-in terminal in VSCode) CD into the directory you want to use for your project. For the sake of this tutorial I will use my desktop. In your terminal, enter the following commands.

  npx create-next-app@latest technoy

We are presented with a list of options, we will just keep all of the default options, so:

  • No to typescript
  • No to ESLint
  • No to Tailwind
  • Yes to src
  • Yes to App router
  • No to Customise the import alias

    Next.JS will go ahead and install all the dependancies, this will only take a minute. We'll then install the other dependancies we need to create our static blog:

  npm install gray-matter markdown-to-jsx

OK, that's all the dependancies taken care of. Next thing we'll do is delete all the starter files we don't need. We want to start fresh!

Lets get the server running so we can see what we are working with. In the terminal type:

  npm run dev

go to localhost:3000/ in your browser and you'll see the default Next.JS starter page.

Firstly in src > app delete the pages.module.css file. Completely get rid of it. Then in the src > app open the global.css file and replace the contents of it with our CSS.

  * {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --bg-color: rgb(239, 236, 232);
  --text-color: rgb(105, 114, 114);
  --heading-color: rgb(75, 85, 85);
  --primary-color: rgb(2, 162, 146);
}

body {
  font-family: var(--font-fira);
  font-size: 16px;
  line-height: 1.6;
  color: var(--text-color);
  background-color: var(--bg-color);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  height: 100vh;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-abril);
  margin-bottom: 1rem;
  color: var(--heading-color);
}

h1 {
  font-size: 3rem;
  line-height: 3.6rem;
}

h4 {
  font-size: 2rem;
  line-height: 2.3rem;
}

a {
  text-decoration: none;
  color: var(--primary-color);
  font-weight: 700;
  border-bottom: 2px solid transparent;
  transition: border 0.3s ease;
}

a:hover:not(#logo) {
  border-bottom: 2px solid var(--primary-color);
}

p {
  margin-bottom: 1.6rem;
}

body::after {
  content: 'technoy';
  color: var(--bg-color);
  text-shadow: 0px 0 64px rgba(202, 195, 187, 0.4);
  font-family: var(--font-abril);
  font-size: 18rem;
  position: fixed;
  top: 50%;
  left: 90%;
  z-index: -1;
  transform: translateX(-48%) translateY(-50%) rotate(-90deg);
}

header,
main,
footer {
  width: 80vw;
  margin: 0 auto;
}

#logo {
  width: 100%;
  display: block;
  margin-top: 0;
  padding: 1rem 2rem;
  font-size: 1.75rem;
  font-family: var(--font-abril);
  position: relative;
}
#logo::after {
  content: '';
  display: inline-block;
  border: 2px solid var(--heading-color);
  width: 4.5%;
  position: absolute;
  top: 80%;
  left: 2rem;
}

article {
  width: 50%;
  padding: 1rem 2rem 4rem;
}

main {
  display: flex;
  flex-wrap: wrap;
  margin: 10% auto;
}

main img {
  object-fit: cover;
}

main h1 {
  width: 70%;
  margin: 2rem auto 3rem;
}

.post {
  margin-top: 3rem;
}

.post-content {
  width: 70%;
  margin: 3rem auto;
}

main.category-page h1 {
  margin: 2rem;
}

footer {
  padding: 2rem;
  text-align: center;
  font-size: 0.75rem;
}

That's all the CSS we will need in this project. I won't explain it as this is article is focused on Next.JS and if your reading this post, you're probably already good with CSS!

Layouts in Next.JS

First thing we will do is take a look at the layout.js in our app folder. The layout.js file is a special file that can be used by multiple files. In genereal it wraps our content. So if we have common elements in our site, like the navigation bar, or the footer, we can wrap our layouts in this file, then our pages files will be passed in as children. Layout files, preseve state and do not re-render. We have our global layout file, but you can also specify specific layout files for specific routes.

Replace the code in the layout.js with the following:

import Link from 'next/link';
import { Abril_Fatface, Fira_Sans } from 'next/font/google';
import './globals.css';

const fira = Fira_Sans({
  subsets: ['latin'],
  weight: ['400', '600'],
  variable: '--font-fira',
});

const abril = Abril_Fatface({
  subsets: ['latin'],
  weight: ['400'],
  variable: '--font-abril',
});

export const metadata = {
  title: 'Tech Blog',
  description: 'A blog on everything tech',
};

export default function RootLayout({ children }) {
  return (
    <html lang='en'>
      <body className={`${abril.variable} ${fira.variable}`}>
        <header>
          <Link id='logo' href='/'>
            technoy.
          </Link>
        </header>
        {children}
        <footer>
          Copyright Technoy {new Date().getFullYear()}.
        </footer>
      </body>
    </html>
  );
}

Lets go through line by line and talk about whats happening:

  1. Import the Link component from Next
  2. Import our google fonts
  3. Create our font objects for the 2 fonts
  4. Our standard metadata - this can be overridden for each page. We won't be doing anything here with this
  5. Our root layout component. In here we are adding a class name to the body tag, and passing it the font object variables. This will allow us to use the font variables now in our global.css
  6. We add a header element and within that add our Logo inside of a Link component which is linked to the home page '/'
  7. Then we have our {children} props. The children props in this instance will be any of our pages.
  8. A footer element, I'm using the new Date().getFullYear() function to get the current year, this way I don't have to worry about updating the year manually every year.

Posts

Now lets add some posts to our blog. From the repo you downloaded earlier, there is a folder called posts, copy the entire folder over into the src directory. This folder contains 5 posts, that are all in markdown language All of the posts have the same format: in the front matter(the meta data of our file) we have: id, slug, title, date, categories, img, excerpt. And then we have the contents of our article.

The first thing we will do is set up a function that we can use to grab the front matter and then we will display them on the home page as teasers for each blog post.

In the src directory create a new folder called utils and in here create a file named getPostsMeta.js

Add the following to the getPostMeta.js file:

import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';


const getPostData = (basePath, slug = null) => {
  // grab the path to the blog posts directory
  const folder = path.join(process.cwd(), `${basePath}/`);

  // grab the contents of the directory
  const files = fs.readdirSync(folder);

  //Only get files ending with '.md' and store in array
  const markdownFiles = files.filter((file) => file.endsWith('.md'));

  // loop through and grab the front matter
  const posts = markdownFiles.map((filename) => {

  // read the contents of the files 
  const fileContents = fs.readFileSync(`${folder}${filename}`, 'utf8');

  // Parse the front matter into an obj
    const matterResult = matter(fileContents);

  // if we've passed in a slug, return the slug
    if (slug) {
      return {
        slug: matterResult.data.slug,
      };
    }

    // return an obj with data to display
    return {
      id: matterResult.data.id,
      slug: matterResult.data.slug,
      title: matterResult.data.title,
      date: matterResult.data.date,
      categories: matterResult.data.categories,
      excerpt: matterResult.data.excerpt,
      img: matterResult.data.img,
    };
  });
  // sort date into latest post
  return posts.sort((a, b) => new Date(b.date) - new Date(a.date));
};

// call getPostData return the result
export const getPostMeta = (basePath) => {
  const posts = getPostData(basePath);
  return posts;
};

// We will use this to generate our slugs later
export const getPostSlug = (basePath) => {
  const slugs = getPostData(basePath, 'true');
  return slugs;
};

OK, lets break down what's going on here:

  • First we are importing the dependencies we need to grab the blog post files from our sytetm.
  • We grab the file paths based on the basePath we pass in later when we call the function.
  • Next we ensure that we are only grabbing any files ending with .md and store in an array.
  • We then loop through the array and parse the file contents and return the front matter we want as an object.
  • Finally we sort the returned posts by the date and return the posts.
  • The getPostSlug() function, we will use this later to generate our slugs later as we want to generate our static pages later on and cache them at build time.

Lets now display the data on the home page.

Open the page.js file thats in the app directory: app > page.js replace the entire contents of this file with the following:

 import Link from 'next/link';
 import { getPostMeta } from '@/utils/getPostMeta';

  export default function Home() {
   
    const posts = getPostMeta('src/posts'); 

    return (
      <main>
        {posts &&
          posts.map((post) => {
            const { id, title, slug, data, categories, excerpt } = post;
            return (
              <article>
                  <h4>{title}</h4>
                  <p>
                    <strong>Published: </strong>
                    {date} <br />
                  </p>
                    <Link href={`/blog/category/${cat}`}>{cat}</Link>
                    {categories.length - 1 > idx ? ' | ' : ''}{' '}
                  <p>{excerpt}</p>
                  <Link href={`/blog/${slug}`}>Read full</Link>
                </article>
            );
          })}
      </main>
    );
  }

OK, so we are importing the Link component from next, as we will use this when we want to link to the post page, and we are importing our getPostsMeta utility function,

  • In our Home page component we are calling the getPostsMeta() fuction and passing in the path to the posts directory, this will return all or our posts front matter objects stored in an array, that we can now map through and output on screen.
  • No we map through through the posts array and for each post we destructure the data, which we then pass use within our component. Now would be a good time to move this article into it's own component, that we can then reuse later. so...
  • Create a new folder in src called components then within that create a new file callied Post.jsx (note: i'm going to use .jsx for all of my files, but you can continue to use .js if you like. This is just my personal preference). src > components > Post.jsx then add the following:
 import Link from 'next/link';
 
const Post = ({ title, slug, date, categories, excerpt }) => {
    return (
      <article>
        <h4>{title}</h4>
        <p>
          <strong>Published: </strong>
          {date} <br />
        </p>
         <Link href={`/blog/category/${cat}`}>{cat}</Link>
         {categories.length - 1 > idx ? ' | ' : ''}{' '}
        <p>{excerpt}</p>
        <Link href={`/blog/${slug}`}>Read full</Link>
      </article>
    );
  };

export default Post;

Now back in src > page.jsx import the post component and replace the <article>...</article> with <Post key={id} {...post} />

the page.jsx file should now look like:

import { getPostMeta } from '@/utils/getPostMeta';
import Post from '@/components/Post';

export default function Home() {
  const posts = getPostMeta('src/posts');

  return (
    <main>
      {posts &&
        posts.map((post) => {
          const { id } = post;
          return <Post key={id} {...post} />;
        })}
    </main>
  );
}

if you go to your browser now, you should see a nicely formatted homepage displaying all the posts. Lets now create out post detail page.

Routes in Next.JS

The app folder is our root folder, all of our pages will live in this folder. Currently in contains a layout.js, a page.js file and a global.css file. In Next.JS we use folders to determine out page routes. We want our url to look something like localhost:300/blog/post-name so we to start we want a blog directory. If later we want an about or contact page, in our app directory we would create a about directory or a contact directory and within that a page component, which would be our content for that route.

in the app directory create a new folder and call it blog, next create a new folder inside the blog directory and call it [slug]. any folder inside the app folder that uses [xxxx] is considered a dynamic route. This means right now, we don't know what all of the pages will be in here as we are creating the blog posts to be rendered dynamically rather than hard code in a new page for each blog post, using [xxxx] we can use one template use that across all our posts. the name in between the '[]' can be whatever you like, but convention is [id] or [slug] we will use the name later when we want to capture the name of the page when we go to display the post. Now create a page.jsx in the [slug] directory

Add the following to the page.jsx in the [slug] folder:

import Image from 'next/image';
import fs from 'fs';
import matter from 'gray-matter';
import Markdown from 'markdown-to-jsx';
import { getPostSlug } from '@/utils/getPostMeta';

// getStaticParams, to generate routes at build time
export const generateStaticParams = async () => {
  const slugs = getPostSlug('src/posts');
  return slugs.map((post) => {
    return { slug: post.slug };
  });
};

const getPageContent = (slug) => {
  const filePath = `src/posts/${slug}.md`;
  const post = fs.readFileSync(filePath, 'utf8');
  const matterResult = matter(post);
  return matterResult;
};

const ArticlePage = ({ params }) => {
  const post = getPageContent(params.slug);

  return (
    <main className='post'>
      <h1>{post.data.title}</h1>
      <Image
        alt={post.data.title}
        src={post.data.img}
        width={2048}
        height={400}
      />

      <div className='post-content'>
        <p>
          <strong>Published: </strong>
          {post.data.date} <br />
          <Link href={`/blog/category/${cat}`}>{cat}</Link>
            {categories.length - 1 > idx ? ' | ' : ''}{' '}
        </p>
        <Markdown>{post.content}</Markdown>
      </div>
    </main>
  );
};

export default ArticlePage;

That's a lot of code, lets break it down.

  • import all of our dependencies and also crucially the getPostMeta function. We will use this with the generateStaticParams function which at build time will statically generate our routes at build time instead of each time a post page is requested going off to the server at run time. This will greatly improve the speed and performance of our site.
  • We then call the generateStaticParams function for the reason mentioned above. The reason we have this function call in this page component, is because this is a dynamic page route so any pages hitting this route need to be generated at build time.
  • Next we create a function called getPageContent...three guesses what this does! it grabs the filespath and slug passed in as an argument the goes off and grabs that file, parses the content then returns the parsed data.
  • Now into our ArticlePage component, and we call that function then pass it into our page using <Markdown></Markdown> component. This will format the page nicely and convert the markdown to JSX.

If you now go back to the browser and click the Read full link, you'll be taken to the article page, and see the full article.

The last thing to do now is to hook up the categories, so that when you click on a category link, you'll be taken to a page where only posts in that category are displayed.

In app > blog create a new folder called categoy this will create a route that looks like localhost:3000/blog/category inside the category folder we now need to create a dynamic route that will allow us to have a url that looks something like localhost:3000/blog/category/post-name call the new folder [cat] inside that folder, create a new page.jsx and add the following:

import { getPostMeta } from '@/utils/getPostMeta';
import Post from '@/components/Post';

const CategoryPage = ({ params }) => {
  const posts = getPostMeta('src/posts');

  const cats = posts.filter((post) => {
    // filter to see if categories contains the param
    return post.categories.includes(params.cat);
  });

  return (
    <>
      <main className='category-page'>
        <h1>{params.cat}:</h1>
        {cats.map((post) => (
          <Post key={post.id} {...post} />
        ))}
      </main>
    </>
  );
};

export default CategoryPage;

So in this file, we are pretty much doing the same thing as on the homepage, but instead of showing all the posts, we are only showing the posts that are in the category. How do we get the category you ask?! glad you asked, when you go to a dynamic route we can get the dynamic part of the route from the params property in the props that automatically get passed to the page. If you take a look at the

    const CategoryPage = ({ params }) => {...}

you can see we are destructuring the props and grabbing the params, this is how we can capture the specific category and then use that to filter out posts. if you were to console.log(params) you'd get an object with the name of the dynamic route and the value for example:

    // url: localhost:300/blog/tech/post-name
    console.log(params) // {cat: tech}

Now all that's left to do is run npm run build and deploy your site,

This was my first attempt at a full length blog post, I plan to make a video of this tutorial where I can explain things in more detail. If you made it this far, thanks for reading!