DEV Community

Cover image for Next.js Blog using Static Site Generation with MongoDB Atlas
SeongKuk Han
SeongKuk Han

Posted on

Next.js Blog using Static Site Generation with MongoDB Atlas

Image description

https://nextjs-isr-example-hsk-kr.vercel.app/


It's been a while since I was interested in Next.js. Nowadays, Next.js seems to be a crucial choice for React developers. There may be some people who think that Next.js is beneficial when they develop SSR-based web applications, however, in fact, it has many benefits even when you plan to make a CRA-based app.

Although I mainly used to work on React projects, I didn't get to work on projects based on Next.js. As I'm currently unemployed, I thought it would be good to try Next.js at this time.


Static Site Generation

In Next.js, pages using Static Generation are generated at build time, which means it doesn't incur any further costs associated with accessing a database or requesting APIs after building.

Most importantly, SSG is amazingly easy to use. Basically, if you don't add any options to pages, it will generate HTML files by default. I wanted to try it to see by myself.


I created a blog example with MongoDB Atlas. The basic plan of Atlas, which provide a database for free with limitation is enough for my project, plus, I wanted to give it a try as well.

From now on, I am going to talk about the following:

  • MongoDB Atlas
  • Post List
  • Post
  • Build

MongoDB Atlas

Create a database user

Database Access Tab in Atalas

After creating a database, navigate to the Database Access tab in the sidebar. Click the tab and click the ADD NEW DATABASE USER button.

Add User Modal

In the modal, you can add a user to the database.

Connect a database

I installed a MongoDB Compass and used it to connect to the database I made.

Database

In the database tab, click the connect button in the database you are going to use.

Connection Methods

Select a method to connect the database. In my case, I selected the Compass option.

How to connect with Compass

It will provide clear instructions on connecting to the database.

Insert dummy data into the database

Create datbase

After connecting to a database in Compass, if you click the plus icon next to Databases, this modal will pop up and you can create a database. I created a database languages-blog.

Create Collection

Create Collection Modal

Adding a collection is as easy as creating a database. Hover the database you want to add a collection in, then click the plus button. You will see the modal you can create a collection. Personally, I really liked UI/UX of Compass.

add data using json file

I inserted the dummy data using json file - https://github.com/hsk-kr/nextjs-isr-example/blob/main/data/posts.json - which is generated by ChatGPT.

Connect DB from Next.js project

import { Db, MongoClient, ReadConcern } from 'mongodb';

declare global {
  var mongoConn: MongoClient | undefined;
}

if (!process.env.MONGODB_URI) {
  throw new Error('Set Mongo URI to .env');
}

const createConnection = async () => {
  const uri = process.env.MONGODB_URI ?? '';
  const client = new MongoClient(uri, {});
  return await client.connect();
};

const updateGlobalMongoConn = async () => {
  if (global.mongoConn) {
    global.mongoConn.close();
  }
  global.mongoConn = await createConnection();
  global.mongoConn.on('timeout', updateGlobalMongoConn);
  global.mongoConn.on('error', updateGlobalMongoConn);
  global.mongoConn.on('connectionCheckOutFailed', updateGlobalMongoConn);
  global.mongoConn.on('connectionPoolClosed', updateGlobalMongoConn);
  global.mongoConn.on('serverClosed', updateGlobalMongoConn);
};

export const executeDB = async <R extends unknown>(
  cb: (db: Db) => R | Promise<R>,
  options: {
    useCache: boolean;
  } = {
    useCache: false,
  }
): Promise<R> => {
  let conn: MongoClient;
  if (options.useCache) {
    if (!global.mongoConn) {
      await updateGlobalMongoConn();
    }

    // The error should not occur after updateGlobalMongoConn is called.
    if (!global.mongoConn) {
      throw new Error('global.mongoConn is not defined.');
    }

    conn = global.mongoConn;
  } else {
    conn = await createConnection();
  }

  const db = conn.db(process.env.DB_NAME);
  const cbResult: R = await cb(db);

  if (!options.useCache) {
    conn.close();
  }

  return cbResult;
};
Enter fullscreen mode Exit fullscreen mode

I defined the executeDB function to ensure that the connection is close after using it.

Additionally, the useCache option allows you to utilize the cached connection and reduce the overhead time that is needed when opening and closing a connection.


Post List

Post List

Page

import { getPosts } from '@/lib/db/posts';
import Blog from './components/Blog';
import { Metadata } from 'next';
import { generateMetaTitleAndDesc } from '@/lib/seo';

const title = 'Blog';
const description = 'Articles motivate you to start learning languages.';

export const metadata: Metadata = {
  ...generateMetaTitleAndDesc(title, description),
};

export default async function BlogPage() {
  const posts = await getPosts();

  return <Blog posts={posts} />;
}
Enter fullscreen mode Exit fullscreen mode

The page is very simple, it retrieves blog posts from the db and it will execute in build time, which means that it generates a static HTML file.

Blog

'use client';

import { Post } from '@/types/blog';
import BlogListItem from '../BlogListItem';
import Paging, { PAGE_CNT } from '../Paging';
import SearchInput from '../SearchInput';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { convertDateFormatForPost, estimateReadingTime } from '@/lib/blog';
import { ComponentProps, useMemo } from 'react';

interface BlogProps {
  posts: Post[];
}

export default function Blog({ posts }: BlogProps) {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();
  let page = Number(searchParams.get('page') ?? 1);
  page = Number.isNaN(page) ? 1 : page;
  const keyword = searchParams.get('keyword') ?? '';

  const filteredPostsByKeyword = useMemo(() => {
    if (!keyword) return posts;

    return posts.filter((post) =>
      post.title.toLowerCase().includes(keyword.toLowerCase())
    );
  }, [posts, keyword]);

  const filteredPosts = useMemo(() => {
    const startIdx = PAGE_CNT * (page - 1);
    const endIdx = startIdx + PAGE_CNT - 1;
    const posts = [];

    for (
      let i = startIdx;
      i <= endIdx && i < filteredPostsByKeyword.length;
      i++
    ) {
      posts.push(filteredPostsByKeyword[i]);
    }

    return posts;
  }, [filteredPostsByKeyword, page]);

  const handleSearch: ComponentProps<typeof SearchInput>['onSearch'] = (
    keyword
  ) => {
    router.replace(`${pathname}?page=1&keyword=${keyword}`);
  };

  return (
    <div>
      <SearchInput
        onSearch={handleSearch}
        searchResultCnt={filteredPostsByKeyword.length}
        keyword={keyword}
      />
      <div className="flex flex-col gap-y-4 pt-6">
        {filteredPosts.map((post) => (
          <BlogListItem
            key={post._id}
            id={post._id}
            title={post.title}
            content={post.content}
            createdAt={convertDateFormatForPost(post.createdAt)}
            estimatedTime={estimateReadingTime(post.content)}
          />
        ))}
      </div>
      <div className="w-fit mx-auto pt-3">
        <Paging
          activePage={page}
          totalElements={filteredPostsByKeyword.length}
        />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Filtering is processed in the Blog component and applies parameters using query string.

Since it holds all posts, it may not be suitable if the data is large. I supposed it would be acceptable as 1kb per two posts was estimated.

If the data is expected large data, fetching a part of posts with parameters may be a better choice.

getPosts

export const getPosts = async () => {
  return await executeDB<Post[]>(async (db) => {
    try {
      const posts = await db
        .collection('posts')
        .aggregate<Post>([
          {
            $project: {
              _id: {
                $toString: '$_id',
              },
              title: 1,
              createdAt: {
                $dateFromString: {
                  dateString: '$createdAt',
                },
              },
              content: {
                $substr: ['$content', 0, 200],
              },
            },
          },
          {
            $sort: {
              createdAt: -1,
            },
          },
        ])
        .toArray();
      return posts;
    } catch (e) {
      console.error(e);
      return [];
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

The getPosts is used to display a brief information of each post and the all text of the content field doesn't need to be shown.

I used $substr to extract a part of the string from the content field.

$dateFromString is used to convert the type of string date to the date type.

$id is used to convert objectId to a string.


Post

Post

page

import Post from './components/Post';
import MorePosts from './components/MorePosts';
import { getMorePosts, getPost, getPostIds } from '@/lib/db/posts';
import { notFound } from 'next/navigation';
import { convertDateFormatForPost } from '@/lib/blog';
import { generateMetaTitleAndDesc } from '@/lib/seo';

interface PostPageProps {
  params: {
    postId: string;
  };
}

export async function generateMetadata({ params: { postId } }: PostPageProps) {
  const post = await getPost(postId);

  if (!post) {
    return {
      title: 'Not Found',
      description: 'The page you are looking for does not exist.',
    };
  }

  return {
    ...generateMetaTitleAndDesc(post.title, post.content.substring(0, 150)),
  };
}

export async function generateStaticParams() {
  const postIds = await getPostIds();

  return postIds.map((postId) => ({
    postId,
  }));
}

export default async function PostPage({ params: { postId } }: PostPageProps) {
  const post = await getPost(postId);
  const posts = await getMorePosts({ exceptionId: postId });

  if (!post) {
    notFound();
  }

  return (
    <>
      <Post
        title={post.title}
        content={post.content}
        createdAt={convertDateFormatForPost(post.createdAt)}
      />
      <div className="border-b-[1px] border-gray-800 my-6" />
      <MorePosts
        posts={posts.map((post) => ({
          id: post._id,
          title: post.title,
          createdAt: convertDateFormatForPost(post.createdAt),
        }))}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

There is no difference in fetching data from the database. The difference is that it needs the generateStaticParams function to generate static files.

The function passes all post ids to the page and static contents will be generated in build time.

getPostIds

export const getPostIds = async () => {
  return await executeDB<string[]>(async (db) => {
    try {
      const posts = await db
        .collection('posts')
        .aggregate<{ _id: string }>([
          {
            $project: {
              _id: {
                $toString: '$_id',
              },
            },
          },
        ])
        .toArray();

      return posts.map((post) => post._id);
    } catch (e) {
      console.error(e);
      return [];
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

It's the same as the getPosts function and the difference is that it includes only the _id field.

getPost

export const getPost = async (id: string) => {
  return await executeDB<Post | null>(async (db) => {
    try {
      const post = await db.collection('posts').findOne<Post>(
        {
          _id: new ObjectId(id),
        },
        {
          projection: {
            _id: {
              $toString: '$_id',
            },
            title: 1,
            content: 1,
            createdAt: {
              $dateFromString: {
                dateString: '$createdAt',
              },
            },
          },
        }
      );

      if (post === null) throw new Error('not found');
      return post;
    } catch (e) {
      console.error(e);
      return null;
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

It retrieves a post corresponding on _id.

getMorePosts

export const getMorePosts = async ({
  exceptionId,
  size = 3,
}: {
  exceptionId?: string;
  size?: number;
}) => {
  return await executeDB<PostWithoutContent[]>(async (db) => {
    try {
      const morePosts = await db
        .collection('posts')
        .aggregate<PostWithoutContent>([
          {
            ...(exceptionId
              ? {
                  $match: {
                    _id: {
                      $ne: new ObjectId(exceptionId),
                    },
                  },
                }
              : {}),
          },
          {
            $project: {
              _id: {
                $toString: '$_id',
              },
              title: 1,
              createdAt: {
                $dateFromString: {
                  dateString: '$createdAt',
                },
              },
            },
          },
          { $sample: { size } },
        ])
        .toArray();

      return morePosts;
    } catch (e) {
      console.error(e);
      return [];
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

The getMorePosts function retrieves random posts except one post.

$match is used to exclude a post.

$sample selects random documents from a collection.


Build

Image description

As you can see, all files are generated as static HTML, and the post pages are generated with SSG.


Wrap up

Next.js provides everything we need in web development. It was impressive how it made everything easy.

You may be able to save noticeable costs using static site generation appropriately.

You can check all the code introduced in the article here - Github Repository.

I hope you find it useful.

Have a happy coding!

Top comments (0)