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
After creating a database, navigate to the Database Access
tab in the sidebar. Click the tab and click the ADD NEW DATABASE USER
button.
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.
In the database tab, click the connect
button in the database you are going to use.
Select a method to connect the database. In my case, I selected the Compass
option.
It will provide clear instructions on connecting to the database.
Insert dummy data into the database
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
.
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.
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;
};
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
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} />;
}
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>
);
}
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 [];
}
});
};
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
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),
}))}
/>
</>
);
}
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 [];
}
});
}
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;
}
});
}
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 [];
}
});
};
The getMorePosts
function retrieves random posts except one post.
$match
is used to exclude a post.
$sample
selects random documents from a collection.
Build
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)