Back to blog

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>
  );
}