Crafting a Dynamic Blog with Next.js 13 App Directory
This article was originally published on Cosmic
Introduction
Blogging is more than a hobby; for many, it's a profession or a way to establish thought leadership in a specific industry. Having a performant and SEO-friendly platform is essential. In this guide, we'll delve into building a blog template with the new features in Next.js 13 App Router.
Creating a Blog Template:
Explore the steps to design a responsive, efficient, and dynamic blog template. This tutorial emphasizes the advantages of leveraging Next.js 13, Cosmic, and a responsive design toolkit for a top-notch user and developer experience.
Key Tools Used:
Next.js 13: The first iteration of the renowned React framework to introduce the App Router, packed with enhanced features for full-stack development.
Cosmic: A headless CMS enables the independence of the data (content) layer and gives us the ability to quickly manage template content. In this case, our blog posts, authors and tags.
Tailwind CSS: A performant utility-first CSS framework that can be composed directly in your markup.
TypeScript: To ensure type safety across our project, if you prefer JavaScript you can rename all your
tsx
files asjsx
(andts
tojs
) and resolve the errors.
Tutorial Breakdown
TL;DR
Setting Up Next.js 13 App Directory
Initialize a new application with the latest Next.js features.
Install the necessary dependencies for the project.
pnpx create-next-app@latest nextjs-developer-portfolio
# or
yarn create next-app nextjs-developer-portfolio
# or
npx create-next-app@latest nextjs-developer-portfolio
Then install the dependencies.
cd nextjs-developer-portfolio
pnpm install
# or
cd nextjs-developer-portfolio
yarn
# or
cd nextjs-developer-portfolio
npm install
Let’s fire up our application! After running the command below, you can open up http://localhost:3000 in your browser.
pnpm run dev
# or
yarn dev
# or
npm run dev
OR
Clone the template from GitHub (recommended)
We have a very simple project setup, which maps closely to our content model (more on this later).
|— app
|— author
|— [slug]
page.tsx
|— posts
|— [slug]
page.tsx
layout.tsx
page.tsx
|— components
|— fonts
|— lib
// the rest is typical Next.js structuring
Configuring Cosmic
To get started with integrating headless content, sign up for Cosmic with a free account and install the demo bucket for Simple Next.js Blog. After creating your account, create a new project. You will be prompted to start with either an empty project or a template. Select “Template”, then select the “Simple Next.js Blog” template that we are using in this tutorial to follow along with demo content
Building the Content Model
As mentioned earlier, our content model maps very closely to our app structure. We have Object types for Author and Posts, and we have Object types for our Categories which are used by our PostCard component to render the badges in the UI.
If you haven’t installed the template, you’ll need to build a custom content model. To understand what data you’ll need for this, you’ll want to refer to the lib/types
file which will show you the model structure as TypeScript types.
Note that in this case, id
, slug
and title
are the default properties given to us when we make a new Object Type, and everything inside the metadata
object is based on the specific model metafields we want to integrate.
// lib/types.ts
export interface GlobalData {
metadata: {
site_title: string;
site_tag: string;
};
}
export interface Post {
id: string;
slug: string;
title: string;
metadata: {
published_date: string;
content: string;
hero?: {
imgix_url?: string;
};
author?: {
slug?: string;
title?: string;
metadata: {
image?: {
imgix_url?: string;
};
};
};
teaser: string;
categories: {
title: string;
}[];
};
}
export interface Author {
id: string;
slug: string;
title: string;
metadata: {
image?: {
imgix_url?: string;
};
};
}
The Content modeller allows you to drag and drop your metafields to build up your model or modify the existing model too.
Integrating the Cosmic SDK
To start working with Cosmic data, you’ll need to install the Cosmic SDK and initialize the Bucket client.
npm i @cosmicjs/sdk
Then, if you haven’t used our provided project code, create a file called cosmic.ts
in a lib
folder and make a call to create the bucket client like so.
ty// lib/cosmic.ts
import { createBucketClient } from '@cosmicjs/sdk';
const cosmic = createBucketClient({
// @ts-ignore
bucketSlug: process.env.NEXT_PUBLIC_COSMIC_BUCKET_SLUG ?? '',
// @ts-ignore
readKey: process.env.NEXT_PUBLIC_COSMIC_READ_KEY ?? '',
});
export default cosmic;
You can now import this wherever you need to fetch Cosmic data from your bucket. You can replace the environment variables with hard coded keys whilst you develop if you wish. Get your keys from Your Project > Bucket > API Access.
Setting Environment Variables
- Alternatively, if you’re using Vercel (recommended) to host your project, you can add your
BUCKET_SLUG
andREAD_KEY
to your project and link it to Vercel using the Vercel CLI.
Fetching Blog Posts
To fetch blog posts, we can use a simple call to the Cosmic SDK and just get back exactly what we need.
tytytytytytytyty// lib/cosmic.ts
export async function getAllPosts(): Promise<Post[]> {
try {
// Get all posts
const data: any = await Promise.resolve(
cosmic.objects
.find({
type: 'posts',
})
.props('id,type,slug,title,metadata,created_at')
.depth(1)
);
const posts: Post[] = await data.objects;
return Promise.resolve(posts);
} catch (error) {
console.log('Oof', error);
}
return Promise.resolve([]);
}
Here we use a Promise to declare an array of our Post
type from the lib/types
file and use the special objects.find()
method to get the contents that matches our type of Posts. We then ask for specific props that we’ll need later when we render the UI. We have a reference to depth(1)
, this is because we only need a single layer of metadata. If we had nested object relationship, we’d need to declare a deeper depth.
Before the return statement, try adding console.log(posts)
to make sure you’re getting a response back from Cosmic. That way you can be sure your environment variables are working correctly and you’ll see the data structure. You can check the Developer Tools drawer and the Node.js
tab to see if it matches what you’ve got in there too.
Pro tip: This is a handy place to get a basic API request from too. Note we’ve added type safety in our implementation.
Markdown or Content Formatting
In this project, we’re using Rich Text to display our post contents, so we use Cosmic’s Rich Text Editor metafield. This means we do dangerously set our HTML in the UI. The Cosmic Rich Text editor offers handy shortcuts to make content creation a breeze.
It is highly recommended to use an XSS Sanitizer like DOMPurify to sanitize HTML and prevent XSS attacks. For Next.js projects, which prominently feature server-side rendering, Isomorphic DOMPurify is especially valuable. It offers a seamless sanitization process across both server and client, ensuring consistent HTML sanitization in environments like Next.js where a native server-side DOM isn't present.
If you’d rather not dangerously set your HTML, there are packages out there that wrap this functionality to provide other ways to present Rich Text.
You can also choose to convert this to Markdown and use our Markdown metafield instead if you prefer, just note you’ll need to install a markdown package to do so. The article Building React Components from headless CMS markdown is a great read about how a package like React Markdown parses markdown from a headless CMS, and explains how to render markdown in a Next.js application.
Designing the Blog Post Overview
So now we’re getting our Post
data back, we need to display it on the page. Let’s do this in our main page.tsx
file as that’s where we want our list of Posts to be shown by default. This is a blog after all.
First we’ll need a card to display our Posts. This relies on some sub components too, but let’s scaffold the main UI first.
Create a new PostCard.tsx
file and start by exporting a PostCard component that expects to receive a post
of type Post
coming in.
// compoonents/PostCard.tsx
export default function PostCard({ post }: { post: Post }) {
return (
// The rest of our code will go here
)
};
We know our code expects to get the following key parts: an image, a slug and a title. So let’s put these in.
// components/PostCard.tsx
export default function PostCard({ post }: { post: Post }) {
return (
{post.metadata.hero?.imgix_url && (
<Link href={`/posts/${post.slug}`}>
<Image width={2800} height={400} className='mb-5 h-[400px] w-full rounded-xl bg-no-repeat object-cover object-center transition-transform duration-200 ease-out hover:scale-[1.02]'
src={`${post.metadata.hero?.imgix_url}?w=1400&auto=format`}
priority alt={post.title}
placeholder='blur'
blurDataURL={`${post.metadata.hero?.imgix_url}?auto=format,compress&q=1&blur=500&w=2`} />
</Link>
)}
)
};
Here, we’re guarding against not having an image url. Although adding a hero is not optional in our metafields, it is possible that one won’t get return if the network is too slow or the imgix
server isn’t responding. This protects against this.
You’ll likely notice this line too:
// components/PostCard.tsx
src={`${post.metadata.hero?.imgix_url}?w=1400&auto=format`}
This uses imgix optimised URL parameters to provide us back with an image at the right size and format for our needs.
Now let’s add the title.
// components/PostCard.tsx
export default function PostCard({ post }: { post: Post }) {
return (
{post.metadata.hero?.imgix_url && (
<Link href={`/posts/${post.slug}`}>
<Image width={2800} height={400} className='mb-5 h-[400px] w-full rounded-xl bg-no-repeat object-cover object-center transition-transform duration-200 ease-out hover:scale-[1.02]'
src={`${post.metadata.hero?.imgix_url}?w=1400&auto=format`}
priority alt={post.title}
placeholder='blur'
blurDataURL={`${post.metadata.hero?.imgix_url}?auto=format,compress&q=1&blur=500&w=2`} />
</Link>
)}
<h2 className='pb-3 text-xl font-semibold tracking-tight text-zinc-800 dark:text-zinc-200'>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
</h2>
)
};
Great, next we want to include the Author’s avatar and an attribution for them. We’ll need to get these data based on the specific post
we’re rendering. So let’s create components that we can pass our post
into to get the right results.
Our Author avatar is nice and simple...
// components/AuthorAvatar.tsx
import Image from 'next/image';
import Link from 'next/link';
import { Post } from '../lib/types';
export default function AuthorAvatar({ post }: { post: Post }): JSX.Element {
return (
<Link href={`/author/${post.metadata.author?.slug}`}>
<Image className='h-8 w-8 rounded-full'
src={`${post.metadata.author?.metadata.image?.imgix_url}?w=100&auto=format`}
width={32}
height={32}
alt={post.title}
/>
</Link>
);
}
And so is our attribution...
// components/AuthorAttribution.tsx
import { Post } from '../lib/types';
import helpers from '../helpers';
export default function AuthorAttribution({ post }: { post: Post }): JSX.Element {
return (
<div className='flex space-x-1'>
<span>by</span>
<a href={`/author/${post.metadata.author?.slug}`} className='font-medium text-green-600 dark:text-green-200'>
{post.metadata.author?.title}
</a>
<span>on {helpers.stringToFriendlyDate(post.metadata.published_date)}</span>
</div>
);
}
So now in our PostCard.tsx
component, we can import these and pass in the post
along with the rest of the code required to make the card render our data.
// components/PostCard.tsx
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import ArrowRight from './icons/ArrowRight';
import Tag from './Tag';
import { Post } from '../lib/types';
import AuthorAttribution from './AuthorAttribution';
import AuthorAvatar from './AuthorAvatar';
export default function PostCard({ post }: { post: Post }) {
return (
{post.metadata.hero?.imgix_url && (
<Link href={`/posts/${post.slug}`}>
<Image width={2800} height={400} className='mb-5 h-[400px] w-full rounded-xl bg-no-repeat object-cover object-center transition-transform duration-200 ease-out hover:scale-[1.02]'
src={`${post.metadata.hero?.imgix_url}?w=1400&auto=format`}
priority alt={post.title} placeholder='blur'
blurDataURL={`${post.metadata.hero?.imgix_url}?auto=format,compress&q=1&blur=500&w=2`} />
</Link>
)}
<h2 className='pb-3 text-xl font-semibold tracking-tight text-zinc-800 dark:text-zinc-200'>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
</h2>
<div className='flex flex-col justify-between space-y-4 md:flex-row md:space-y-0'>
<div className='flex items-center space-x-2 text-zinc-500 dark:text-zinc-400 md:space-y-0'>
<AuthorAvatar post={post} />
<AuthorAttribution post={post} />
</div>
<div className='flex select-none justify-start space-x-2 md:hidden md:justify-end'>
{post.metadata.categories && post.metadata.categories.map((category) => <Tag key={category.title}>{category.title}</Tag>)}</div>
</div>
<div className='py-6 text-zinc-500 dark:text-zinc-300' dangerouslySetInnerHTML={{ __html: post.metadata.teaser ?? '' }} />
<div className='flex items-center justify-between font-medium text-green-600 dark:text-green-200'>
<Link href={`/posts/${post.slug}`}>
<div className='flex items-center space-x-2'>
<span>Read more</span>
<ArrowRight className='h-4 w-4 text-inherit' />
</div>
</Link>
<div className='hidden select-none justify-end space-x-2 md:flex '>{post.metadata.categories && post.metadata.categories.map((category) => <Tag key={category.title}>{category.title}</Tag>)}</div>
</div>
</div>
)
};
Displaying the Blog Overview
Now we’ve got our card and we’ve got it rendering our data, we need to put this into our main page.tsx
. Thanks to the new App Router structure with React Server Components, we don’t need to utilise special functions like getServerSideProps()
to fetch our data. Instead, we can simply await our getAllPosts()
function and return the data to the view.
// app/page.tsx
import React from 'react';
import PostCard from '../components/PostCard';
import { getAllPosts } from '../lib/cosmic';
export default async function Page(): Promise<JSX.Element> {
const posts = await getAllPosts();
return (
// The rest of our code will go here
)
}
So, to return our list of posts (if you’ve used the template, you already have 5 example posts, otherwise you’ll need to add some) we simply need to map over our returned array of data and pass it to our PostCard
component to render.
// app/page.tsx
import React from 'react';
import PostCard from '../components/PostCard';
import { getAllPosts } from '../lib/cosmic';
export default async function Page(): Promise<JSX.Element> {
const posts = await getAllPosts();
return (
<main className="mx-auto mt-4 w-full max-w-3xl flex-col space-y-16 px-4 lg:px-0">
{!posts && "You must add at least one Post to your Bucket"}
{posts &&
posts.map((post) => {
return (
<div key={post.id}>
<PostCard post={post} />
</div>
);
})}
</main>
);
}
Generating Individual Blog Post Pages
So great, we’ve got a nice list of blog posts now… but we can’t see any of them when we click anything.
I won’t cover the entire code necessary to render the individual blog post (you can find that in the sample code, but I’ll cover a few important App Router elements we’ll need to consider.
First is the generateMetadata()
function. This allows us to create dynamic metadata based on the given blog post that’s being viewed. This is important for good SEO and also when sharing via social platforms. You can go wild with this, generating dynamic OG images with custom titles and other data if you want.
In our case, we’re keeping it simple.
// app/posts/[slug]/page.tsx
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost({ params });
return {
title: `${post.title} | Simple Next 13 Blog`,
};
}
Here we simply return the current blog post’s title to the title property in our metadata object. This means the title in the browser tab/window, as well as when shared on social media, will match our current blog post.
One other useful element we have, which leverages the power of the Cosmic SDK, is the ability to show suggested posts that aren’t the current post you’re viewing.
In the UI, this looks like this (where SuggestedPostCard is very similar in structure to the typical PostCard, just with a simplified UI).
// app/posts/[slug]/page.tsx
<div className='flex flex-col space-x-0 space-y-4 md:flex-row md:space-x-4 md:space-y-0'>
{suggestedPosts.slice(0, 2).map((post) => {
return <SuggestedPostCard key={post.id} post={post} />;
})}
</div>
The way we prevent getting the post we’re currently on, is by passing an extra property to our find()
method. Here’s just the relevant fetch part of the code.
// lib/cosmic.ts
const data: any = await Promise.resolve(
cosmic.objects
.find({
type: 'posts',
slug: {
$ne: params?.slug,
},
})
.props(['id', 'type', 'slug', 'title', 'metadata', 'created_at'])
.sort('random')
.depth(1)
);
Notice that we say we want any where the slug
is not equal to the params?.slug
. To do this, we passed in the params to the function like so export async function getRelatedPosts({ params }: { params: { slug: string } }): Promise<Post[]>
. This means that when we reference it in our page and pass the page params
, the function knows to avoid that current slug.
The fetch that does this, looks like so:
// app/posts/[slug]/page.tsx
const suggestedPosts = await getRelatedPosts({ params });
Deployment
Now, if you’ve chosen to follow along exactly, there’ll be some missing pieces before you can actually deploy this project. Take a look at the sample code to see what you might need and make adjustments accordingly to get it up and running.
We host our example on Vercel, so as noted earlier, it’ll be easy to get set up if you’re using it. Otherwise, you can push this up to any other platform of choice such as Netlify.
Conclusion
By following this tutorial, you'll have a modern, dynamic, and efficient blog template powered by Next.js 13 App directory. Engage with the developer community, share your experiences, and always look for ways to improve and innovate. Your blog is not just a platform; it's an evolving entity that reflects your voice and expertise.
Subscribe to my newsletter
Read articles from Karl directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Karl
Karl
Design Engineer by day at DuckDuckGo and by night at Cosmic. Helping startups from time-to-time with 0 → 1 products. Part of the “Cracked Photoshop & MySpace” generation. Listener and creator of heavy music.