Thursday, February 6, 2025
How to Build a Next.js 15 REST API From Scratch with Prisma and MongoDB
Posted by
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
- Create a MongoDB Atlas account at https://www.mongodb.com/cloud/atlas
- Create a new project
- Build a new cluster (choose the FREE tier)
- Click "Connect" on your cluster
- Add your IP address to the IP Access List
- Create a database user (save these credentials!)
- Choose "Connect your application"
- 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
- 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.
- Prisma Generate Issues
# If you get Prisma Client errors, try:
pnpm dlx prisma generate
pnpm install
- 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.