DEV Community

Cover image for Crafting a Dynamic Blog with Next.js 13 App Directory
Karl
Karl

Posted on

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 as jsx (and ts to js) and resolve the errors.

Tutorial Breakdown

TL;DR

Install the template

View the live demo

View the code

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
Enter fullscreen mode Exit fullscreen mode

Then install the dependencies.

cd nextjs-developer-portfolio
pnpm install
# or
cd nextjs-developer-portfolio
yarn
# or
cd nextjs-developer-portfolio
npm install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

// 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;
Enter fullscreen mode Exit fullscreen mode

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 and READ_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.

// 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([]);
}
Enter fullscreen mode Exit fullscreen mode

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
  )
};
Enter fullscreen mode Exit fullscreen mode

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>
    )}
  )
};
Enter fullscreen mode Exit fullscreen mode

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`}
Enter fullscreen mode Exit fullscreen mode

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>
  )
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
};
Enter fullscreen mode Exit fullscreen mode

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
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
 );
}
Enter fullscreen mode Exit fullscreen mode

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.

Image of Blog Page

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`,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
lalami profile image
Salah Eddine Lalami

Thanks for sharing