Cover Image for Building a Robust Repository Layer with Next.js 14 and MongoDB: A Comprehensive Guide
Abdulrahman Muhialdeen
Abdulrahman Muhialdeen

Building a Robust Repository Layer with Next.js 14 and MongoDB: A Comprehensive Guide

I’ve used Next.js 13 in many of my projects, but I was eager to explore the new features in version 14. In this article, we’ll delve into implementing server actions with a repository layer using Next.js 14 along with a MongoDB database.

I’ve used Next.js 13 in many of my projects, but I was eager to explore the new features in version 14. In this article, we’ll delve into implementing server actions with a repository layer using Next.js 14 along with a MongoDB database.

Let’s kick things off by creating a new Next.js project:

npx create-next-app@latest

Make sure to enable typescript and Tailwind CSS during the project creation. Navigate to the root folder:

cd ./root

Now, let's install the necessary packages using pnpm to generate the pnpm-lock.yaml file:

pnpm install

MongoDB Connections

To establish connections with MongoDB, we need to install the MongoDB package:

pnpm add mongodb

Create a new folder named /lib under src/and add a file named mongodb.ts inside it. This file will contain our database connections class:

import { MongoClient } from 'mongodb';
 
// Replace with your MongoDB connection string
const uri = process.env.MONGODB_URI as string; 
const options = {};
 
declare global {
  var _mongoClientPromise: Promise<MongoClient>;
}
 
class Singleton {
  private static _instance: Singleton;
  private client: MongoClient;
  private clientPromise: Promise<MongoClient>;
 
  private constructor() {
    this.client = new MongoClient(uri, options);
    this.clientPromise = this.client.connect();
 
    if (process.env.NODE_ENV === 'development') {
      // In development mode, use a global variable to preserve the value
      // across module reloads caused by HMR (Hot Module Replacement).
      global._mongoClientPromise = this.clientPromise;
    }
  }
 
  public static get instance() {
    if (!this._instance) {
      this._instance = new Singleton();
    }
    return this._instance.clientPromise;
  }
}
 
const clientPromise = Singleton.instance;
 
// Export a module-scoped MongoClient promise.
// By doing this in a separate module, 
// the client can be shared across functions.
export default clientPromise;

This code sets up a MongoDB connection class, providing a singleton instance for efficient connection management. Remember to replace the placeholder in process.env.MONGODB_URI with your actual MongoDB connection string.

Please be aware that the clientPromise will be invoked later in our repository class to establish the database connection.

Creating the Repository Layer

Following the establishment of the MongoDB connections class, let’s build a generic service layer that dynamically queries the MongoDB client instance using T. To achieve this, create a new folder at src\Server\RepositoryService and add two files: IRepositoryService.ts and RepositoryService.ts.

Inside IRepositoryService.ts, define a typescript interface for the service:

interface IRepository<T> {
  find(
    filter: Partial<T>,
    page: number,
    limit: number,
    projection?: Partial<Record<keyof T, 1 | 0>>,
  ): Promise<{ data: T[], totalCount: number }>;
}

In RepositoryService.ts, implement the find() method:

import clientPromise from "@/lib/mongodb";
import { MongoClient } from "mongodb";
 
export class Repository<T> implements IRepository<T> {
  private collection: string;
 
  constructor(collection: string) {
    this.collection = collection;
  }
 
  // Asynchronously find documents in the collection
  async find(
    filter: Partial<T>,
    page: number = 1,
    limit: number = 10,
    projection?: Partial<Record<keyof T, 1 | 0>>,
  ): Promise<{ data: T[], totalCount: number }> {
    try {
      // Await the client promise to get an instance of MongoClient
      const client: MongoClient = await clientPromise;
 
      // Calculate how many documents to skip
      const skip = (page - 1) * limit;
 
      // Access the database and the collection
      const collection = client.db().collection(this.collection);
 
      // Get the total count of all items
      const totalCount = await collection.countDocuments(filter);
 
      // Access the database and the collection, then find documents matching the filter
      // If a projection is provided, apply it to the query
      // Convert the result to an array and return it
      const data = await collection
        .find(filter, { projection })
        .skip(skip)
        .limit(limit)
        .toArray();
 
      return { data: data as unknown as T[], totalCount };
    } catch (error: unknown) {
      // Catch and log any connection errors
      if (error instanceof Error) {
        if (error.message.includes("ECONNREFUSED")) {
          console.error("Failed to connect to MongoDB. Connection refused.");
        } else {
          console.error("An error occurred:", error.message);
        }
      }
      return { data: [], totalCount: 0 };
    }
  }
}

The find() method supports filtering, projection, and pagination, returning a promise that resolves to an object with matching documents and the total count.

Creating the Book Entity

To utilize the repository layer, create an entity service. Let’s use BookService for this. Create the src\Server\Service\BookService folder with files IBookService.ts and BookService.ts.

In IBookService.ts, define the interface:

export type IBook = {
  // ... (book properties)
};
 
export interface IBookService {
  searchBook(filter: Partial<IBook>): Promise<{ data: IBook[], totalCount: number }>;
}

Create BookService.ts:

import { Repository } from "@/Server/RepositoryService/RepositoryService";
import { IBook, IBookService } from "./IBookService";
 
export class BookService implements IBookService {
  private repository: Repository<IBook>;
 
  constructor() {
    this.repository = new Repository<IBook>("books");
  }
 
  async searchBook(
    filter: Partial<IBook>,
    page: number = 1,
    limit: number = 10
  ): Promise<{ data: IBook[], totalCount: number }> {
    return this.repository.find(filter, page, limit);
  }
}

Server Actions

In Next.js 14, server actions can be used directly without creating APIs. Create src/Server/actions/BookActions.ts:

'use server';
 
import { BookService } from "../Service/BookService/BookService";
 
export const fetchBooks = async (pageNumber: number) => {
  const bookService = new BookService();
  return await bookService.searchBook({}, pageNumber, 10);
};

Make sure to include 'use server' for using the action function inside the React component.

Updating Home Component

Finally, in src/app/page.tsx, integrate everything:

import React, { Suspense } from 'react';
import { BookCard } from '@/Client/Components/Home/BookCard';
import { fetchBooks } from '@/Server/actions/BookActions';
import Pagination from '@/Client/Components/Home/Pagination';
type ISearchQuery = {
  page: string;
}
type HomeProps = {
  searchParams?: { [key: string]: string | string[] | undefined };
}
export default async function Home({
  searchParams
}: HomeProps) {
  // get the current page number
  const { page } = searchParams as ISearchQuery;
  const pageNumber = page && !isNaN(Number(page)) ? Number(page) : 1;
 
  /* begin:: feetch book list */
  const { data: books, totalCount } = await fetchBooks(pageNumber);
  /* end:: feetch book list */
 
 
  return (
    <div className="home-page w-full px-8 grow flex flex-col mt-2">
      <h2>Books:</h2>
      <hr className='w-full my-3 h-1 border-stone-400' />
      <span className='text-sm'>{books.length} of {totalCount} Items </span>
      <div className="mt-6 result-container">
        <Suspense fallback={<h6 className='text-center ltr'>📡 Loading data please wait ... </h6>}>
          {books?.length ?
            <>
              {books.map((book, index) => (<BookCard key={index} {...book} />))}
              <Pagination
                currentPage={pageNumber}
                totalItems={totalCount}
                itemsPerPage={10} // replace this with your actual items per page  
              />
            </>
            :
            <h4 className='text-center'>No books found</h4>}
        </Suspense>
      </div>
    </div>
  );
}

This component uses fetchBooks() to get books on the server side and renders them using the <BookCard> component.

Conclusion

In conclusion, this article has walked you through the implementation of a robust repository layer in Next.js 14, leveraging MongoDB for database interactions. By creating a generic service layer, you can seamlessly query the MongoDB client instance dynamically using typescript generics. The demonstrated example includes the creation of a Book entity service, server actions, and the integration of these components into a Next.js application.

The repository layer, as showcased, provides a flexible and reusable solution for handling database operations, supporting features such as filtering, projection, and pagination. By utilizing server actions in Next.js 14, you can directly invoke server-side functionality without the need for additional API endpoints, enhancing the efficiency of your application.

As you embark on implementing similar solutions in your projects, feel free to customize and extend the presented concepts to suit your specific use cases. I hope this article proves valuable in enhancing your understanding of Next.js 14 and its capabilities for building powerful and scalable applications.

Thank you for reading, and I sincerely hope you find this article insightful and beneficial for your development journey. Wish you the best of luck, and I hope you enjoy implementing these concepts in your own projects!