Sean Kerwin
Posted on September 2, 2022
In this post, I'm going to build a blog demo app using Next.js and the blog will be powered by Contentlayer.
We'll be making a statically generated, fast and simple blog with no need for a backend.
Contentlayer will power the blog functionality using markdown files that we can commit to our repo.
Let's get started.
First, lets create a new Next.js application, you can follow the official guide here. I will be using yarn
for this, but you can use npm
or pnpm
also.
yarn create next-app
You will be presented with some options if you use the above command, like the application name.
Once that has ran, you should navigate into that folder you just created.
Installing Contentlayer
Head over to the official docs if you want to read more about Contentlayer.
Add Contentlayer
yarn add contentlayer next-contentlayer
Once that has done, open your code in an IDE and open the next.config.js
file, it should look like this.
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
}
module.exports = nextConfig
Change it to add the withContentlayer
import
const { withContentlayer } = require('next-contentlayer')
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
}
module.exports = withContentlayer(nextConfig)
With that out the way, we need to create a new file at the root of our project called jsconfig.json
or tsconfig.json
if using TypeScript.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/components/*": ["components/*"],
"contentlayer/generated": ["./.contentlayer/generated"]
}
},
"include": ["next-env.d.ts", "**/*.jsx", "**/*.js", ".contentlayer/generated"]
}
Next we're going to want to create a contentlayer config file in the root of our project called contentlayer.config.js
This is what I have in mine
import { defineDocumentType, makeSource } from 'contentlayer/source-files'
const computedFields = {
slug: {
type: 'string',
resolve: (doc) => doc._raw.sourceFileName.replace(/\.md$/, ''),
},
}
export const Post = defineDocumentType(() => ({
name: 'Post',
filePathPattern: `posts/*.md`,
fields: {
title: { type: 'string', required: true },
publishedAt: { type: 'string', required: true },
tags: { type: 'string' },
image: { type: 'string' },
},
wordCount: {
type: 'number',
resolve: (doc) => doc.body.raw.split(/\s+/gu).length,
},
computedFields,
}))
export default makeSource({
contentDirPath: 'data',
documentTypes: [Post],
})
What this is doing is specifying a single document type called Post
and they're all going to live inside a folder called data/posts/*.md
.
You can read more about how this works here.
We're going to create two new folders, in the root of our project, create a folder called data
, then inside that, create another called posts
. The reason we're nesting it like this, is once you get your head around Contentlayer, you can extend the config above to define different document types, for instance, you might want to have Posts
, Projects
and Guides
all stored inside your code.
Inside the data/posts
folder, create some markdown files that have the following format:
---
title: "Lorem Ipsum"
publishedAt: 2022-06-24
tags: ['Nextjs', 'React'] // optional
image: '/static/post-1-hero.webp' // optional
---
Mollit nisi cillum exercitation minim officia velit laborum non Lorem
adipisicing dolore. Labore commodo consectetur commodo velit adipisicing irure
dolore dolor reprehenderit aliquip. Reprehenderit cillum mollit eiusmod
excepteur elit ipsum aute pariatur in. Cupidatat ex culpa velit culpa ad non
labore exercitation irure laborum.
The title and publishedAt are required, but the tags and image are not, they're optional, (we specified this in the config above) so try and create some posts with and without the optional fields.
The folder structure should be the following:
docs/
├─ posts/
│ ├─ post-1.md
│ ├─ post-2.md
│ ├─ post-3.md
You are free to call these markdown files whatever you want, keep them lower-case and hyphenated as this will be the slug/url for that post.
Before we go any further, lets test that contentlayer is all hooked up correctly.
yarn dev
If all works, you should get an output a bit like the following:
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info - SWC minify release candidate enabled. https://nextjs.link/swcmin
Generated 3 documents in .contentlayer
event - compiled client and server successfully in 1225 ms (178 modules)
wait - compiling...
event - compiled client and server successfully in 49 ms (178 modules)
Notice the Generated 3 documents in .contentlayer
bit, that tells us that we've hooked contentlayer up and its generated 3 files based on our markdown files.
If we look in our project, a .contentlayer
folder has been created, don't edit anything inside of here as it gets regenerated each time things are changed. But if we go take a look, we should have a folder inside called generated
and a Post
folder inside that.
.
└── .contentlayer/
├── .cache
└── generated/
└── Post/
├── _index.json
├── _index.mjs
├── posts__post-1.md.json
├── posts__post-2.md.json
└── posts__post-3.md.json
Take a look at one of the files, posts__post-1.md.json
and it will look something like this:
{
"title": "My first blog post",
"publishedAt": "2022-06-24T00:00:00.000Z",
"tags": [
"Nextjs",
"React"
],
"image": "/static/post-1-hero.webp",
"body": {
"raw": "\nMollit nisi cillum exercitation minim officia velit laborum non Lorem\nadipisicing dolore. Labore commodo consectetur commodo velit adipisicing irure\ndolore dolor reprehenderit aliquip. Reprehenderit cillum mollit eiusmod\nexcepteur elit ipsum aute pariatur in. Cupidatat ex culpa velit culpa ad non\nlabore exercitation irure laborum.\n",
"html": "<p>Mollit nisi cillum exercitation minim officia velit laborum non Lorem\nadipisicing dolore. Labore commodo consectetur commodo velit adipisicing irure\ndolore dolor reprehenderit aliquip. Reprehenderit cillum mollit eiusmod\nexcepteur elit ipsum aute pariatur in. Cupidatat ex culpa velit culpa ad non\nlabore exercitation irure laborum.</p>"
},
"_id": "posts/post-1.md",
"_raw": {
"sourceFilePath": "posts/post-1.md",
"sourceFileName": "post-1.md",
"sourceFileDir": "posts",
"contentType": "markdown",
"flattenedPath": "posts/post-1"
},
"type": "Post",
"slug": "post-1"
}
In this file, we can see what contentlayer has generated, we've got body.html
from our Markdown content, our slug, tags, date and title all there. Now lets use that inside react!
Rendering the posts
We're going to keep this simple by just using the index.js
page to fetch our content, but we could if we wanted to, break this out into a separate page.
Open up the pages/index.js
file and delete everything inside of it, we're going to start fresh.
I'm a big fan of arrow functions but if you want to use a traditional function, that's fine.
Place this inside the inedx.js
file to get started.
const Index = () => {
return (
<div>
Hello world.
</div>
)
}
export default Index
If you run the app with yarn dev
now, and open it in a browser, http://localhost:3000
, you should see a blank page with hello world
in the corner. If so, great! Let's move on.
Now update your index.js file to have the following:
import { allPosts } from "contentlayer/generated"
export async function getStaticProps() {
const posts = allPosts.sort((a, b) => Number(new Date(b.publishedAt)) - Number(new Date(a.publishedAt)))
return { props: { posts } }
}
const Index = ({ posts }) => {
console.log(posts)
return (
<div>
Hello world!
</div>
)
}
export default Index
What we're doing here is adding the allPosts
import that contentlayer generated, remember the .contentlayer
folder, take a look in there at the _index.mjs
file, that's where the allPosts
function comes from.
We're going to use Next.js getStaticProps
method (which is asynchronous) - getStaticProps
is a method to tell Next.js to pre-render this page at build time, and use the props provided by getStaticProps
. You can read more about this here.
We're then passing posts
to the Index
method, and if you console.log(posts)
inside that, you'll be able to see them on the running app console.
I also created a folder at the root of my project called static
with some images inside post-1-hero.webp
which is what is in the markdown file for the image
tag.
This is my final index.js
file
import { allPosts } from "contentlayer/generated"
import Image from "next/image"
export async function getStaticProps() {
const posts = allPosts.sort((a, b) => Number(new Date(b.publishedAt)) - Number(new Date(a.publishedAt)))
return { props: { posts } }
}
const Index = ({ posts }) => {
return (
<div>
<div className="posts">
<h1>Posts</h1>
{posts.map((post, i) => (
<div key={i} className="post">
<div>
<h2>{post.title}</h2>
<span>{post.publishedAt}</span>
{/* Loop through any tags if we have any */}
<ul>
{post.tags && post.tags.map((tag, i) => (
<li key={i}>{tag}</li>
))}
</ul>
</div>
{ post.image && (
<div style={{ width: '200px', position: 'relative', height: '100px' }}>
<Image src={post.image} alt={post.title} layout="fill" objectFit="cover" />
</div>
)}
{/* Post body */}
<p>{post.body.raw}</p>
</div>
))}
</div>
</div>
)
}
export default Index
If you've followed this guide, you should be able to start your project
yarn dev
and see some posts
on the page.
This will all be statically generated at build time which is great for speed, no need for a back-end to store your blog posts any more, just add a new post markdown file, commit it, and run a build/deploy.
Whilst there are so many things that can be done with Contentlayer, you can look at adding pagination, fetching next and previous posts and having multiple document types, all within the same code-base.
I really like contentlayer and the fact that it means I can write blog posts/article and just store them in my Git repo, posts can then be version controlled and I don't have the overhead of running a server to store the posts.
Feel free to checkout this code in my repo here.
I also use Contentlayer on my personal website, you can see the code for that here or see it in action by going to seankerwin.dev
Posted on September 2, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.