Back to Guide

Thursday, June 12, 2025

Next.js 15 API Cheatsheet with Prisma ORM

cover

Next.js 15 API Cheatsheet with Prisma ORM

Table of Contents

Setup

1. Install Dependencies

npm install prisma @prisma/client
npm install -D prisma

2. Initialize Prisma

npx prisma init

3. Prisma Client Setup

Create lib/prisma.ts:

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

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Prisma Schema Example

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql" // or "mysql", "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

GET Requests

1. Get All Records

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  try {
    const users = await prisma.user.findMany({
      include: {
        posts: true, // Include related posts
      },
      orderBy: {
        createdAt: "desc",
      },
    });

    return NextResponse.json(users);
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to fetch users" },
      { status: 500 }
    );
  }
}

2. Get Single Record

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const user = await prisma.user.findUnique({
      where: {
        id: parseInt(params.id),
      },
      include: {
        posts: true,
      },
    });

    if (!user) {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }

    return NextResponse.json(user);
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to fetch user" },
      { status: 500 }
    );
  }
}

3. Get Records with Search Parameters

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);

    // Extract query parameters
    const page = parseInt(searchParams.get("page") || "1");
    const limit = parseInt(searchParams.get("limit") || "10");
    const search = searchParams.get("search") || "";
    const sortBy = searchParams.get("sortBy") || "createdAt";
    const sortOrder = searchParams.get("sortOrder") || "desc";

    // Calculate pagination
    const skip = (page - 1) * limit;

    // Build where clause for search
    const where = search
      ? {
          OR: [
            { name: { contains: search, mode: "insensitive" as const } },
            { email: { contains: search, mode: "insensitive" as const } },
          ],
        }
      : {};

    // Fetch users with filters
    const [users, total] = await Promise.all([
      prisma.user.findMany({
        where,
        include: {
          posts: true,
        },
        orderBy: {
          [sortBy]: sortOrder,
        },
        skip,
        take: limit,
      }),
      prisma.user.count({ where }),
    ]);

    return NextResponse.json({
      users,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit),
      },
    });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to fetch users" },
      { status: 500 }
    );
  }
}

4. Get Posts with Advanced Filtering

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);

    const published = searchParams.get("published");
    const authorId = searchParams.get("authorId");
    const search = searchParams.get("search");

    const where: any = {};

    // Filter by published status
    if (published !== null) {
      where.published = published === "true";
    }

    // Filter by author
    if (authorId) {
      where.authorId = parseInt(authorId);
    }

    // Search in title and content
    if (search) {
      where.OR = [
        { title: { contains: search, mode: "insensitive" } },
        { content: { contains: search, mode: "insensitive" } },
      ];
    }

    const posts = await prisma.post.findMany({
      where,
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true,
          },
        },
      },
      orderBy: {
        createdAt: "desc",
      },
    });

    return NextResponse.json(posts);
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to fetch posts" },
      { status: 500 }
    );
  }
}

POST Requests

1. Create User

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { email, name } = body;

    // Validation
    if (!email) {
      return NextResponse.json({ error: "Email is required" }, { status: 400 });
    }

    // Check if user already exists
    const existingUser = await prisma.user.findUnique({
      where: { email },
    });

    if (existingUser) {
      return NextResponse.json(
        { error: "User with this email already exists" },
        { status: 409 }
      );
    }

    const user = await prisma.user.create({
      data: {
        email,
        name,
      },
    });

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to create user" },
      { status: 500 }
    );
  }
}

2. Create Post with Relationship

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { title, content, authorId, published = false } = body;

    // Validation
    if (!title || !authorId) {
      return NextResponse.json(
        { error: "Title and authorId are required" },
        { status: 400 }
      );
    }

    // Verify author exists
    const author = await prisma.user.findUnique({
      where: { id: parseInt(authorId) },
    });

    if (!author) {
      return NextResponse.json({ error: "Author not found" }, { status: 404 });
    }

    const post = await prisma.post.create({
      data: {
        title,
        content,
        published,
        authorId: parseInt(authorId),
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true,
          },
        },
      },
    });

    return NextResponse.json(post, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to create post" },
      { status: 500 }
    );
  }
}

PATCH vs PUT

PATCH is used for partial updates - you only send the fields you want to change.

// app/api/users/[id]/route.ts
export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const body = await request.json();
    const { name, email } = body;

    // Build update data object with only provided fields
    const updateData: any = {};
    if (name !== undefined) updateData.name = name;
    if (email !== undefined) updateData.email = email;

    // Check if there's anything to update
    if (Object.keys(updateData).length === 0) {
      return NextResponse.json(
        { error: "No fields provided for update" },
        { status: 400 }
      );
    }

    const user = await prisma.user.update({
      where: {
        id: parseInt(params.id),
      },
      data: updateData,
    });

    return NextResponse.json(user);
  } catch (error) {
    if (error.code === "P2025") {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }
    return NextResponse.json(
      { error: "Failed to update user" },
      { status: 500 }
    );
  }
}

PUT - Full Resource Replacement

PUT is used for complete replacement - you send the entire resource.

// app/api/users/[id]/route.ts
export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const body = await request.json();
    const { name, email } = body;

    // Validation - all required fields must be present
    if (!email) {
      return NextResponse.json(
        { error: "Email is required for full update" },
        { status: 400 }
      );
    }

    const user = await prisma.user.update({
      where: {
        id: parseInt(params.id),
      },
      data: {
        name: name || null, // Explicitly set to null if not provided
        email,
      },
    });

    return NextResponse.json(user);
  } catch (error) {
    if (error.code === "P2025") {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }
    return NextResponse.json(
      { error: "Failed to update user" },
      { status: 500 }
    );
  }
}

🎯 Recommendation: Use PATCH

  • PATCH is more flexible and user-friendly
  • Allows partial updates without affecting other fields
  • Better for APIs where clients might not have all data
  • More efficient as you only send changed fields
  • Follows REST best practices for most use cases

DELETE Requests

1. Delete Single Record

// app/api/users/[id]/route.ts
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    // Optional: Check if user has related data
    const userWithPosts = await prisma.user.findUnique({
      where: { id: parseInt(params.id) },
      include: { posts: true },
    });

    if (!userWithPosts) {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }

    // Option 1: Prevent deletion if user has posts
    if (userWithPosts.posts.length > 0) {
      return NextResponse.json(
        { error: "Cannot delete user with existing posts" },
        { status: 409 }
      );
    }

    // Option 2: Delete user and cascade delete posts
    // const user = await prisma.user.delete({
    //   where: { id: parseInt(params.id) },
    //   include: { posts: true },
    // })

    const user = await prisma.user.delete({
      where: { id: parseInt(params.id) },
    });

    return NextResponse.json({
      message: "User deleted successfully",
      user,
    });
  } catch (error) {
    if (error.code === "P2025") {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }
    return NextResponse.json(
      { error: "Failed to delete user" },
      { status: 500 }
    );
  }
}
// Add deletedAt field to your schema first
// deletedAt DateTime?

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const user = await prisma.user.update({
      where: { id: parseInt(params.id) },
      data: {
        deletedAt: new Date(),
      },
    });

    return NextResponse.json({
      message: "User soft deleted successfully",
      user,
    });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to delete user" },
      { status: 500 }
    );
  }
}

3. Bulk Delete

// app/api/posts/bulk-delete/route.ts
export async function DELETE(request: NextRequest) {
  try {
    const body = await request.json();
    const { ids } = body;

    if (!Array.isArray(ids) || ids.length === 0) {
      return NextResponse.json(
        { error: "Array of IDs is required" },
        { status: 400 }
      );
    }

    const result = await prisma.post.deleteMany({
      where: {
        id: {
          in: ids.map((id) => parseInt(id)),
        },
      },
    });

    return NextResponse.json({
      message: `\${result.count} posts deleted successfully`,
      deletedCount: result.count,
    });
  } catch (error) {
    return NextResponse.json(
      { error: "Failed to delete posts" },
      { status: 500 }
    );
  }
}

Error Handling

Common Prisma Error Codes

function handlePrismaError(error: any) {
  switch (error.code) {
    case "P2002":
      return NextResponse.json(
        { error: "A record with this value already exists" },
        { status: 409 }
      );
    case "P2025":
      return NextResponse.json({ error: "Record not found" }, { status: 404 });
    case "P2003":
      return NextResponse.json(
        { error: "Foreign key constraint failed" },
        { status: 400 }
      );
    default:
      return NextResponse.json(
        { error: "Database error occurred" },
        { status: 500 }
      );
  }
}

Input Validation with Zod

import { z } from "zod";

const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).optional(),
});

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    // Validate input
    const validatedData = createUserSchema.parse(body);

    const user = await prisma.user.create({
      data: validatedData,
    });

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "Validation failed", details: error.errors },
        { status: 400 }
      );
    }
    // Handle other errors...
  }
}

Best Practices

1. Use TypeScript for Type Safety

import { User, Post } from "@prisma/client";

type UserWithPosts = User & {
  posts: Post[];
};

2. Implement Proper Error Handling

  • Always handle Prisma-specific errors
  • Return appropriate HTTP status codes
  • Provide meaningful error messages

3. Use Transactions for Complex Operations

const result = await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({
    data: { email, name },
  });

  const post = await tx.post.create({
    data: {
      title: "Welcome Post",
      authorId: user.id,
    },
  });

  return { user, post };
});

4. Implement Pagination for Large Datasets

Always use skip and take for pagination to avoid performance issues.

5. Use Select and Include Wisely

Only fetch the data you need to optimize performance.

6. Validate Input Data

Always validate incoming data before database operations.

7. Use Environment Variables

Store database URLs and sensitive data in environment variables.

8. Connection Pooling

Prisma handles connection pooling automatically, but be mindful in serverless environments.

Quick Reference

MethodPurposeStatus Code
GETRetrieve data200
POSTCreate new resource201
PATCHPartial update200
PUTFull update/replace200
DELETERemove resource200

Common Query Parameters

  • page & limit - Pagination
  • search - Text search
  • sortBy & sortOrder - Sorting
  • filter - Filtering by specific fields

This cheatsheet covers the essential patterns for building robust APIs with Next.js 15 and Prisma! 🚀

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