Back to Guide

Thursday, February 6, 2025

How to Build a Next.js 15 REST API From Scratch with Prisma and MongoDB

cover

Introduction

In this guide, we'll build a RESTful API using Next.js 15's App Router, Prisma ORM, and MongoDB. We'll create a complete Product API with CRUD operations, following best practices and including helpful tips at each step.

Prerequisites

Before we start, make sure you have:

  • Node.js installed (version 18.17 or later)
  • pnpm installed globally
  • MongoDB Atlas account (we'll walk through setup)
  • Basic understanding of TypeScript and REST APIs

Step 1: Project Setup

Let's create a new Next.js project with TypeScript:

pnpm create next-app@latest product-api
cd product-api

Choose the following options during setup:

✔ Would you like to use TypeScript? Yes
✔ Would you like to use ESLint? Yes
✔ Would you like to use Tailwind CSS? No
✔ Would you like to use `src/` directory? No
✔ Would you like to use App Router? Yes
✔ Would you like to customize the default import alias? No

💡 Tip: Using the App Router in Next.js 15 gives us access to the latest features and better performance optimization.

📝 Note: We're not using the src directory to keep our project structure simpler and more straightforward.

Step 2: Installing Dependencies

Install Prisma and other necessary dependencies:

pnpm add prisma @prisma/client

Initialize Prisma in your project:

pnpm dlx prisma init

💡 Tip: Using pnpm instead of npm provides better dependency management and faster installation times.

Step 3: Setting Up MongoDB Database

Getting Your MongoDB URL

  1. Create a MongoDB Atlas account at https://www.mongodb.com/cloud/atlas
  2. Create a new project
  3. Build a new cluster (choose the FREE tier)
  4. Click "Connect" on your cluster
  5. Add your IP address to the IP Access List
  6. Create a database user (save these credentials!)
  7. Choose "Connect your application"
  8. Copy the connection string

Your connection string will look like this:

mongodb+srv://username:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority

Important: Adding Database Name

Modify the connection string to include your database name:

mongodb+srv://username:<password>@cluster0.xxxxx.mongodb.net/product-db?retryWrites=true&w=majority

Add this to your .env file:

DATABASE_URL="mongodb+srv://username:<password>@cluster0.xxxxx.mongodb.net/product-db?retryWrites=true&w=majority"

💡 Tip: Always include the database name in your connection string. This ensures Prisma creates the collections in the correct database.

📝 Note: Replace username, <password>, and the cluster details with your actual credentials.

Step 4: Creating the Prisma Schema

Create your product schema in prisma/schema.prisma:

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mongodb"
  url      = env("DATABASE_URL")
}

model Product {
  id          String   @id @default(cuid())
  name        String
  description String
  stock       Int
  price       Int
  imageUrl    String
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@map("products")
}

Generate the Prisma Client:

pnpm dlx prisma generate

💡 Tip: The @@map("products") directive tells Prisma what to name the collection in MongoDB. Without it, Prisma would use "Product" as the collection name.

📝 Note: For MongoDB, we use @db.ObjectId for ID fields and @map("_id") to match MongoDB's default ID field name.

Step 5: Creating the Prisma Client Instance

Create a new file lib/db.ts (note we're using db instead of prisma for the export):

// prisma/db.ts

import { PrismaClient } from "@prisma/client";

declare global {
  var prisma: PrismaClient | undefined;
}

export const db = globalThis.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalThis.prisma = db;

💡 Tip: Using a global instance prevents multiple PrismaClient instances during development hot reloading.

📝 Note: We export as db instead of prisma for a more concise and clear naming convention.

Step 6: Implementing API Routes

Create the API route files:

GET All Products and Create Product

Please Create v1 folder which help us to have different versions of our api

import { db } from "@/prisma/db";
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  try {
    // Recieve the data
    const { name, description, stock, imageUrl, price } = await request.json();
    const data = {
      name,
      description,
      stock,
      imageUrl,
      price,
    };
    // SAVE TO THE DB
    const newProd = await db.product.create({
      data,
    });
    revalidatePath("/");
    return NextResponse.json(
      {
        data: newProd,
        error: null,
      },
      { status: 201 }
    );
  } catch (error) {
    console.log(error);
    return NextResponse.json(
      {
        data: null,
        error: "something went wrong",
      },
      { status: 500 }
    );
  }
}

export async function GET() {
  try {
    const products = await db.product.findMany({
      orderBy: {
        createdAt: "desc",
      },
    });
    return NextResponse.json(
      {
        data: products,
        error: null,
      },
      { status: 200 }
    );
  } catch (error) {
    console.log(error);
    return NextResponse.json(
      {
        data: null,
        error: "Something went wrong",
      },
      { status: 500 }
    );
  }
}

📝 Note: We're using proper error handling with specific error messages for validation failures.

GET, UPDATE, and DELETE Single Product

// app/api/v1/products/[productId]/route.ts

import { db } from "@/prisma/db";
import { NextResponse } from "next/server";

export async function GET(
  request: Request,
  { params }: { params: Promise<{ productId: string }> }
) {
  try {
    const productId = (await params).productId;
    const product = await db.product.findUnique({
      where: {
        id: productId,
      },
    });
    return NextResponse.json(
      {
        data: product,
        error: null,
      },
      { status: 200 }
    );
  } catch (error) {
    console.log(error);
    return NextResponse.json(
      {
        data: null,
        error: "Something went wrong",
      },
      { status: 500 }
    );
  }
}
export async function PATCH(
  request: Request,
  { params }: { params: Promise<{ productId: string }> }
) {
  try {
    const productId = (await params).productId;
    // Recieve the data
    const { name, description, stock, imageUrl, price } = await request.json();
    const data = {
      name,
      description,
      stock,
      imageUrl,
      price,
    };
    const updatedProduct = await db.product.update({
      where: {
        id: productId,
      },
      data,
    });
    return NextResponse.json(
      {
        data: updatedProduct,
        error: null,
      },
      { status: 200 }
    );
  } catch (error) {
    console.log(error);
    return NextResponse.json(
      {
        data: null,
        error: "Something went wrong",
      },
      { status: 500 }
    );
  }
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ productId: string }> }
) {
  try {
    const productId = request?.nextUrl?.searchParams.get("productId");

    if (!productId) {
      return NextResponse.json(
        {
          data: null,
          error: "No Product Id Provided",
        },
        { status: 404 }
      );
    }
    console.log(productId);
    await db.product.delete({
      where: {
        id: productId,
      },
    });
    return NextResponse.json(
      {
        success: true,
        error: null,
      },
      { status: 200 }
    );
  } catch (error) {
    console.log(error);
    return NextResponse.json(
      {
        success: false,
        error: "Something went wrong",
      },
      { status: 500 }
    );
  }
}

Step 8: Testing the API

You can test your API using Thunder Client (VS Code Extension) or Postman. Here are the test requests:

Create a Product (POST)

POST http://localhost:3000/api/products
Content-Type: application/json

{
  "name": "Gaming Laptop",
  "description": "High-performance gaming laptop with RTX 4080",
  "price": 1299.99,
  "stock": 30,
  "imageUrl": "https://example.com/laptop.jpg"
}

💡 Tip: Always test with invalid data to ensure your validation works:

Get All Products (GET)

# Get all products
GET http://localhost:3000/api/v1/products

Get Single Product (GET)

GET http://localhost:3000/api/v1/products/{product-id}

📝 Note: Replace {product-id} with an actual product ID from your database.

Update Product (PATCH)

PATCH http://localhost:3000/api/v1/products/{product-id}
Content-Type: application/json

{
  "price": 1199.99,
  "stock": 10
}

💡 Tip: You can update individual fields without sending the entire object.

Delete Product (DELETE)

DELETE http://localhost:3000/v1/api/products/{product-id}

Common Issues and Solutions

  1. MongoDB Connection Issues
# Check your connection string format
mongodb+srv://<username>:<password>@cluster0.xxxxx.mongodb.net/product-db?retryWrites=true&w=majority

💡 Tip: Make sure to URL encode special characters in your password.

  1. Prisma Generate Issues
# If you get Prisma Client errors, try:
pnpm dlx prisma generate
pnpm install
  1. Type Errors
// Always import types from generated Prisma client
import { Prisma } from "@prisma/client";

Consume the POST API Endpoint Using the form and react-hook-form

// components/Product.tsx
"use client";
import React, { useState } from "react";
import {
  Drawer,
  DrawerClose,
  DrawerContent,
  DrawerDescription,
  DrawerFooter,
  DrawerHeader,
  DrawerTitle,
  DrawerTrigger,
} from "@/components/ui/drawer";
import { Button } from "./ui/button";
import { Loader, Pencil, Plus } from "lucide-react";
import { useForm } from "react-hook-form";
import TextInput from "./FormInputs/TextInput";
import TextareaInput from "./FormInputs/TextareaInput";
import { UploadButton } from "@/lib/uploadthing";
import Image from "next/image";
import toast from "react-hot-toast";
import { cn } from "@/lib/utils";
import { IProduct } from "@/actions/products";
interface CreateProductProps {
  name: string;
  price: number;
  description: string;
  imageUrl: string;
  stock: number;
}
export interface ProductFormProps {
  editingId?: string;
  initialData?: IProduct;
  from?: string;
}
export default function ProductForm({
  editingId,
  initialData,
  from = "/",
}: ProductFormProps) {
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm <
  CreateProductProps >
  {
    defaultValues: {
      description: initialData?.description ?? "",
      stock: initialData?.stock ?? 0,
      price: initialData?.price ?? 0,
      name: initialData?.name ?? "",
    },
  };
  const initialImageUrl = editingId
    ? initialData?.imageUrl
    : "/placeholder.png";
  const [imageUrl, setImageUrl] = useState(initialImageUrl);
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
  const [loading, setLoading] = useState(false);

  async function saveProduct(data: CreateProductProps) {
    try {
      data.imageUrl = imageUrl ?? "";
      data.price = Number(data.price);
      data.stock = Number(data.stock);
      setLoading(true);
      if (editingId) {
        const res = await fetch(`${baseUrl}/api/v1/products/${editingId}`, {
          method: "PATCH",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(data),
        });
      } else {
        const res = await fetch(`${baseUrl}/api/v1/products`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(data),
        });
      }
      reset();
      setLoading(false);
      setImageUrl("/placeholder.png");
      toast.success(
        `Product ${editingId ? "updated" : "created"} successfully`
      );
      setTimeout(() => {
        window.location.reload();
      }, 3000);
      // console.log(res);
    } catch (error) {
      console.log(error);
    }
  }

  return (
    <div className="">
      <Drawer>
        <DrawerTrigger asChild>
          {editingId ? (
            <Button variant={"ghost"}>
              <Pencil />
              Edit Product
            </Button>
          ) : (
            <Button
              variant={from === "/" ? "ghost" : "default"}
              className={cn("", from === "/dashboard" && "w-full")}
            >
              <Plus className="mr-2 h-4 w-4" />
              {from === "/" ? " Add Product" : " Add New Product"}
            </Button>
          )}
        </DrawerTrigger>
        <DrawerContent>
          <DrawerHeader>
            <DrawerTitle>
              <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
                Create New Product
              </h3>
            </DrawerTitle>
            <DrawerDescription>
              Create a new Product in the Store
            </DrawerDescription>
          </DrawerHeader>
          <form className="p-4 md:p-5" onSubmit={handleSubmit(saveProduct)}>
            <div className="grid grid-cols-12">
              <div className="md:col-span-8 col-span-full">
                <div className="grid gap-4 mb-4 grid-cols-2">
                  <TextInput
                    label="Product Name"
                    register={register}
                    name="name"
                    errors={errors}
                    placeholder="Type product name"
                  />
                  <TextInput
                    label="Product Price"
                    register={register}
                    name="price"
                    errors={errors}
                    className="col-span-1"
                    type="number"
                    placeholder="200"
                  />
                  <TextInput
                    label="Product stock"
                    register={register}
                    name="stock"
                    errors={errors}
                    className="col-span-1"
                    type={"number"}
                    placeholder="20"
                  />
                  <TextareaInput
                    label="Product Description"
                    register={register}
                    name="description"
                    errors={errors}
                    placeholder="Write product description here"
                  />
                </div>
              </div>
              <div className="md:col-span-4 col-span-full p-8">
                <div className=" shadow-md border rounded-md p-4 py-8">
                  <Image
                    src={imageUrl ?? ""}
                    width={512}
                    height={512}
                    alt=""
                    className="h-36 object-contain rounded-lg"
                  />
                  <UploadButton
                    endpoint="productImageUploader"
                    onClientUploadComplete={(res) => {
                      // Do something with the response
                      console.log("Files: ", res);
                      setImageUrl(res[0].url);
                    }}
                    onUploadError={(error: Error) => {
                      // Do something with the error.
                      alert(`ERROR! ${error.message}`);
                    }}
                  />
                </div>
              </div>
            </div>
            <div className="flex items-center space-x-6">
              {loading ? (
                <button
                  type="submit"
                  disabled
                  className="text-white inline-flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
                >
                  <Loader className="me-1 -ms-1 w-5 h-5 animate-spin" />
                  {editingId ? "Updating..." : "Creating please wait ..."}
                </button>
              ) : (
                <button
                  type="submit"
                  className="text-white inline-flex items-center bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
                >
                  <Plus className="me-1 -ms-1 w-5 h-5" />
                  {editingId ? "Update Product" : " Add new product"}
                </button>
              )}
              <DrawerClose>
                <Button variant="outline">Cancel</Button>
              </DrawerClose>
            </div>
          </form>
        </DrawerContent>
      </Drawer>
    </div>
  );
}

Subscribe to My Latest Guides

All the latest Guides and tutorials, straight from the team.

Subscribe to be the first to receive the new Guide when it comes out

Get All Guides at Once