Next.js Blog using Static Site Generation with MongoDB Atlas
Tags: react, nextjs, webdev, typescript
Published: 2023-11-27 17:10:33


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p2mhjl87t0hhzm02sm8l.png)

[https://nextjs-isr-example-hsk-kr.vercel.app/](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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mndyj8yrkcg704g1onf0.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/hy5kuwg7xglhoqun9tua.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fevkd4q46wi7feempnyn.png)

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

![Connection Methods](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z4vvn01y04y68q95nz83.png)

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

![How to connect with Compass](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fso87rs9a2jc8qb0gxiv.png)

It will provide clear instructions on connecting to the database.

## Insert dummy data into the database

![Create datbase](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/729m4d960kgi4b1qkxyo.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tru8di894b8iu7rru2fa.png)

![Create Collection Modal](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4smr9fl970ukwymenyw6.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uqadukqga5ytkecpzkmc.png)

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

## Connect DB from Next.js project

```typescript
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;
};
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m7phh9v64kdggxbo8rwp.png)

**Page**
```typescript
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} />;
}
```

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**
```typescript
'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>
  );
}
```

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**
```typescript
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 [];
    }
  });
};
```

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/im9erimvelgxttbn7wru.png)

**page**
```typescript
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),
        }))}
      />
    </>
  );
}
```

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**
```typescript
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 [];
    }
  });
}
```

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

**getPost**
```typescript
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;
    }
  });
}
```

It retrieves a post corresponding on `_id`.

**getMorePosts**
```typescript
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 [];
    }
  });
};
```

The `getMorePosts` function retrieves random posts except one post.

`$match` is used to exclude a post.

[`$sample`](https://www.mongodb.com/docs/manual/reference/operator/aggregation/sample/) selects random documents from a collection.

---

# Build

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ik6pg4y0dsjteuf1e38k.png)

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](https://github.com/hsk-kr/nextjs-isr-example).

I hope you find it useful.

Have a happy coding!