In this blog post, I want to build with you an MDX-powered blog with Next.js and Tailwind CSS: at the end of this tutorial, we will have built a free blog template that you can use to deploy your portfolio and your blog today. In addition, we will also be using Typescript and Tailwind CSS.
Getting Started
Bootstrapping the Next.js template with create-next-app
Let's kick-start the project with create-next-app
- a Vercel utility to build a minimal Next.js repository. Fire up your terminal and run the following command:
npx create-next-app --ts
The command should prompt you for the project's name; type it out and continue. The command will then install the packages, and if it all goes well, you should see the output below:
Initialized a git repository.Success! Created mk-next-blog-kit at /Users/MakerKit/mk-next-blog-kitInside that directory, you can run several commands:
It should create a folder structure similar to the one below:
Installing dependencies
Run the command below to install other dependencies we need:
npm i --save rehype-slug rehype-highlight rehype-autolink-headings gray-matter feed @mdx-js/mdx next-sitemap date-fns
Adding Tailwind CSS
Tailwind CSS is the hottest CSS utility framework at the time of writing, and it will likely be for a while.
Create a file named postcss.config.js
in your root folder:
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, },};
And now paste the following configuration to a new file named tailwind.config.js
:
const plugin = require('tailwindcss/plugin');module.exports = { content: ['./**/*.tsx'], darkMode: 'class', corePlugins: { container: false, }, theme: { container: { center: true, padding: { DEFAULT: '1rem', sm: '2rem', }, }, fontFamily: { serif: ['Bitter', 'serif'], sans: [ 'SF Pro Text', 'Inter', 'system-ui', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Ubuntu', ], monospace: [`SF Mono`, `ui-monospace`, `Monaco`, 'Monospace'], }, }, plugins: [customContainerPlugin, plugin(ellipisfyPlugin)],};function ellipisfyPlugin({ addUtilities }) { const styles = { '.ellipsify': { overflow: 'hidden', 'text-overflow': 'ellipsis', 'white-space': 'pre', }, }; addUtilities(styles);}function customContainerPlugin({ addComponents }) { addComponents({ '.container': { '@screen lg': { maxWidth: '1024px', }, '@screen xl': { maxWidth: '1166px', }, }, });}
Of course, feel free to customize the configuration as you prefer.
The last step to activate Tailwind is to edit your styles/global.css
file and prepend the following content to the existing styles:
@tailwind base;@tailwind components;@tailwind utilities;# rest of your file goes here...
Et voilá, we're all set to start using Tailwind CSS across our pages! Now on to building the entities of our blog.
Blog Entities
To start, we create 3 empty directories in the root directory, in which we can place the three main entities of our blog: _posts
, _collections
, and _authors
.
Additionally, we create two more folders, lib
in which we place logic and API, and components,
which hosts the components of our codebase (except for pages, which we add to the pages
folder).
Authors
Here we add the authors of our blog; if you're a solo writer, it can be overkill. If you have a team of writers, you can add them to the _authors
folder as a JSON file with the following interface:
type Author = { name: string; picture: string; url: string;};export default Author;
Collections
Collections are, as the name says, a collection of posts. For example, we included this blog post in the tutorials
collection. If you pay attention to the URL of this blog post, you can notice that we structured it with the slug of the collection tutorials
and the slug of the blog post.
Collections have the following interface:
interface WithEmoji { emoji?: string;}interface WithLogo { logo?: string;}interface Collection extends WithEmoji, WithLogo { name: string; slug: string; emoji: string;}export default Collection;
As you can see, we added the possibility to assign an image or an emoji to the collection.
Posts
This entity contains the data of our blog post, made up of the content of our MDX files and some relative content retrieved using references.
import Author from './author';import Collection from './collection';type BlogPost = { author: Author; collection: Collection; image: string; description: string; slug: string; title: string; publishedAt: string; live: boolean; tags: string[]; readingTime: number; content: string;};export default BlogPost;
Adding our first blog post
Now that we are aware of the shape of our entities and where we can place them, we can add our first blog post. Let's start by adding our first Author, Giancarlo!
Let's create a file named giancarlo.json
at _authors/giancarlo.json
with the following content:
{ "name": "Giancarlo", "picture": "/assets/images/authors/giancarlo.png", "url": "https://twitter.com/gc_psk"}
Next, we can add a collection; let's name it tutorials
just like this post.
Let's create a file named tutorials.json
at _collections/tutorials.json
with the following content:
{ "name": "tutorials", "emoji": "🖥️"}
Finally, we can create a blog post with the name create-blog-post.mdx
at _posts/create-blog-post.mdx
. NB: in this case, we're writing an MDX file. Therefore the extension is .mdx
.
You can use the content below or anything else you may think of:
---title: 'Create an MDX-powered Blog with Next.js'collection: '_collections/tutorials.json'author: '_author/giancarlo.json'publishedAt: 2022-03-30status: "published"image: '/assets/images/posts/create-blog-mdx-nextjs.png'description: "Let's create an MDX-powered blog and portfolio starter that you can deploy right away with Next.js and Tailwind CSS"---## My first blog postYay!
And yay, we added our first blog post!
As you may have noticed, both collection
and author
are references to paths, which we resolve when building the blog posts.
Why add authors and collections as files?
Good question! Eventually, you will want to use a CMS (such as Forestry, Netlify CMS, Tina, etc.); adding this metadata as files means you can add and update them using the CMS rather than being forced to deploy a new version of your code.
This will make it easier to make edits, especially if the blog post will be maintained by non-technical users.
Blog API
We need to define the functions to read the blog posts, collections, and authors.
We create a Typescript file named api.ts
at lib/blog/api.ts
in which we define the utilities to create our blog.
At the top of this file, we define a couple of constants we need to use:
const POSTS_DIRECTORY_NAME = '_posts';const COLLECTIONS_DIRECTORY_NAME = `_collections`;const AUTHORS_DIRECTORY_NAME = `_authors`;const CWD = process.cwd();const postsDirectory = join(CWD, POSTS_DIRECTORY_NAME);const collectionsDirectory = join(CWD, COLLECTIONS_DIRECTORY_NAME);const authorsDirectory = join(CWD, AUTHORS_DIRECTORY_NAME);
Next, let's define the function that allows us to retrieve the list of JSON files at the path specified:
function readJson(directoryName: string) { return readdirSync(directoryName) .map((slug) => { const path = join(CWD, directoryName, slug); if (existsSync(path)) { const json = readFileSync(path, 'utf-8'); try { const data = JSON.parse(json) as Collection; const realSlug = slug.replace('.json', ''); return { data, slug, realSlug }; } catch (e) { console.warn(`Error while reading JSON file`, e); } } } }) .filter(Boolean);}
To read an MDX file, we need a small utility to read the frontmatter
of the markdown file. To do so, we can use the dependency gray-matter
:
import matter from 'gray-matter';export function readFrontMatter(fullPath: string) { try { const fileContents = readFileSync(fullPath, 'utf-8'); return matter(fileContents); } catch (e) { console.warn(`Error while reading Front matter at ${fullPath}`, e); }}
The function below allows us to read a blog post using its slug and return its data and contents.
NB: don't worry about the functions within we haven't defined. We have skipped their implementations for simplicity, but you can find the complete source code at the end of the article.
function getPostBySlug(slug: string) { const postFileName = `${slug}.mdx`; const postPath = join(postsDirectory, postFileName); const file = readFrontMatter(postPath); if (!file) { return; } const content = file.content; const data = file.data; const empty = Object.keys(data).length === 0; if (empty) { return; } const readingTime = getReadingTimeInMinutes(content); const post: Partial<BlogPost> = { live: data.live, readingTime, }; for (const field in data) { if (field === 'slug') { post[field] = slug; continue; } if (field === 'collection') { post[field] = getCollection(data[field]); continue; } if (field === 'author') { post[field] = getAuthor(data[field]); continue; } if (field === 'content') { post[field] = content; continue; } if (field === 'date' && data.date) { try { post[field] = new Date(data.date).toISOString(); continue; } catch (e) { console.error(`Error processing blog post date ${data.date}`); } } if (data[field]) { Object.assign(post, { [field]: data[field], }); } } return post as BlogPost;}
Let's summarize what happens in the function above:
- we read the blog post's matter by using its slug
- we iterate the fields of the MDX file and transform them into a processed object which has the shape
BlogPost
- the collection and the author's data are also collected using the file system and transformed into their relative objects
Time to get our hands dirty with some components.
Add a Next.js Blog Post page
As you may know, Next.js uses file-system routing. As such, we need to define the following path:
[collection] - [...slug].tsx
The collection
folder represents the collection name, while [slug]
is the blog post's slug, which we use to find our blog post. The slug
parameter reflects the way we named our MDX file.
Our goals are the following:
- fetch the correct post using the
slug
parameter provided - fetch a few more posts to display belonging to the same collection
- compile the MDX content to raw HTML
Let's define a function to compile the raw MDX to HTML. To do so, we use the packages we installed in the very beginning:
import rehypeHighlight from 'rehype-highlight';import rehypeSlug from 'rehype-slug';import rehypeAutoLinkHeadings from 'rehype-autolink-headings';export async function compileMdx(markdown: string) { const { compile } = await import('@mdx-js/mdx'); const code = await compile(markdown, { outputFormat: 'function-body', rehypePlugins: [rehypeHighlight, rehypeSlug, rehypeAutoLinkHeadings], }); return String(code);}
It's time to define the [...slug].tsx
page, which is our blog post page.
Generating Blog Posts using getStaticPaths
When creating dynamic pages with SSG, we use getStaticPaths,
a special Next.js function that allows you to create a list of paths generated as static files.
Let's explain the flow behind generating static paths with getStaticPaths
for each of our blog posts:
- Collecting the posts: initially, we collect all the blog posts from the file-system
- Adding the URL parameters: for each blog post collected, we need to pass an object containing the dynamic URL parameters of the page. In our case, we need to pass the collection and the post's slugs
export function getStaticPaths() { const posts = getAllPosts(); const paths = posts.map((post) => { const slug = post.slug; const collection = post.collection.name.toLowerCase(); return { params: { collection, slug, }, }; }); return { paths, fallback: false, };}
We build the array paths
with items such as:
{ collection, slug,}
These parameters are used to match the URL /[collection]/[slug]
; the parameters get passed to the function getStaticProps,
another specific Next.js function that takes the parameters collected and returns data fetched at build-time (i.e., when we build and deploy the Next.js application to Vercel).
We use the getStaticProps
function to fetch a blog post from the file-system and return it to the page component, which is responsible for rendering the page's data.
Passing data to the page component using getStaticProps
Using the data collected by the getStaticPaths
function hook, we can now retrieve the necessary data for rendering our blog posts. We use the API functions we defined above in the post.
Let's summarise the flow:
- Getting the Post: we use the slug (retrieved from the URL parameter) to fetch the post from the file-system using the function
getPostBySlug
- Getting similar posts: retaining readers on blog is hard: it's very valuable to get your readers' attention by displaying other blog posts they could be interested in; we do so by getting the latest posts with the same collection using the function
getPostsByCollection
- Compiling the MDX: finally, we compile the MDX string to a format that our component
MDXRenderer
can display - Passing data to the page component: the data collected will be passed to the page as component properties
export async function getStaticProps({ params }: Params) { const { slug, collection } = params; const maxReadMorePosts = 6; const post = getPostBySlug(slug); if (!post) { return { notFound: true, }; } const morePosts = getPostsByCollection(collection) .filter((item) => item.slug !== slug) .slice(0, maxReadMorePosts); const content = await compileMdx(post.content ?? ''); return { props: { post, content, morePosts, }, };}
NB: to build the collections' and tags' pages, we do something quite similar to the above, but instead of fetching the posts, we fetch a unique set of collections and tags listing the articles assigned to them.
For simplicity, you can read the source code linked at the bottom of this article to check how we do it.
Creating the Blog Post's Page Component
Our blog post's page looks like something similar to the below:
- Header: at the top of the page, we display the page's header
- Post Head: the head contains information and metadata about the post, such as the Open Graph tags, Rich Results structured data, and other useful SEO meta tags. For more information about this, check out the post about boosting your Next.js application SEO and get more traffic to your blog posts
- Post: the content of the blog post, such as the title and the MDX content
- Posts List: the list of the latest posts with the same collection
const PostPage = ({ post, morePosts, content }) => { return ( <> <Header /> <Layout> <PostHead post={post} /> <Post content={content} post={post} /> <PostsList posts={morePosts} /> </Layout> </> );};export default PostPage;
For simplicity, we won't be building all the components listed in this post, but you are free to copy and read the code of the Next.js MDX Blog Starter Template, which was the inspiration for this blog post.
Rendering MDX content as HTML
To convert and render the compiled MDX code to HTML, we need to use a runtime function provided by the package @mdx-js/mdx
.
Below, you can find the component that renders its relative HTML node, given compiled MDX code.
import * as runtime from 'react/jsx-runtime.js';import { runSync } from '@mdx-js/mdx';type MdxComponent = React.ExoticComponent<{ components: Record<string, React.ReactNode>;}>;function MDXRenderer({ code }: { code: string }) { const { default: MdxModuleComponent } = runSync(code, runtime) as { default: MdxComponent; }; return <MdxModuleComponent components={{}} />;}export default MDXRenderer;
Take a good look at the property components
of the MdxModuleComponent
component: at the moment, it's empty. However, we can pass a list of components that we want to be able to render by default in our MDX files.
For example, we can augment the default img
tag with our own. In fact, a common practice is to swap the default img
tag with Next's own and optimized Image
component. Let's see how it's done:
type ImageLayout = 'fixed' | 'fill' | 'intrinsic' | 'responsive' | undefined;type StringObject = Record<string, string>;const NextImage: React.FC<StringObject> = (props: StringObject) => { const width = props.width ?? '4'; const height = props.height ?? '3'; return ( <img width={width} height={height} layout={(props.layout as ImageLayout) ?? 'responsive'} className={props.class} src={props.src} alt={props.alt} {...props} /> );};const MDXComponents = { img: NextImage, Image: NextImage,}export default MDXComponents;
After having defined the object MDXComponents
, we can edit the MDXRenderer
component to include the augmentd components listed above:
return <MdxModuleComponent components={MDXComponents} />;
At this point, all the images will be rendered using Next.js's incredible Image
component; furthermore, we can use the component Image
directly in our MDX files, which allows us to use all the properties available:
<img width={2012} height={680} alt={'Your Image txt'} src={'/assets/images/posts/image.png'}/>
The Makerkit's Next.js Blog Starter template includes the following components: Image,
ExternalLink
, and Video
.
Final Result
Below is the final result of what we built, and what you will find in Blog Starter:
Blog Starter Source Code
The final result of this starter template is a fast, responsive, dark-mode ready, and functional blog that you can use as the foundation for yours. In addition, we have already optimized it for Search Engines, so SEO is also taken care of for you.
You can find the complete source code on GitHub. It's free and open-source, which means you can use it, fork it, and edit it.