Adding a Prisma‑PostgreSQL Checklist Backend to a Next.js App on Vercel
TL;DR: I added a postinstall hook to run prisma generate during Vercel builds and built a full CRUD checklist API with Prisma, PostgreSQL, and Next.js 14. The changes let the app compile on Vercel, expose searchable validation checklists, and send email notifications without manual steps.
The Problem
When I deployed the initial Create‑Next‑App scaffold to Vercel, the build succeeded but the runtime immediately crashed with:
Error: Cannot find module '@prisma/client'
Require stack:
- /vercel/path/to/.next/server/pages/api/admin/apps/route.js
The cause was obvious: Vercel runs npm install && npm run build, but the Prisma client is generated only after a prisma generate step, which was missing from the CI pipeline. Without the generated client, any import of @prisma/client fails, breaking all API routes that rely on it.
At the same time, the project needed a new feature: a searchable, editable checklist for app validation, with an admin UI to manage catalog entries and email notifications on status changes. The existing code base had no database schema, no seed data, and no API endpoints for the checklist.
What I Tried First
My first attempt was to add the Prisma client as a regular dependency and rely on Vercel’s default post‑install behavior:
// package.json (original)
"dependencies": {
"@prisma/client": "^7.8.0"
}
I ran npm run build locally, which succeeded because I had already run npx prisma generate. However, Vercel still failed because the build environment never executed prisma generate. I tried adding a custom Vercel build script in vercel.json, but Vercel overrides the build command and the script never ran.
I also tried generating the client in a separate CI step (GitHub Actions) and committing the generated node_modules/@prisma/client folder, but that broke the lockfile and caused version mismatches.
The Implementation
1. Hook the Prisma generation into Vercel’s lifecycle
The simplest solution is to use npm’s postinstall hook, which Vercel runs after installing dependencies. I added the hook directly to package.json:
// package.json (diff)
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
+ "postinstall": "prisma generate"
},
"dependencies": {
+ "@prisma/adapter-pg": "^7.10.0",
+ "@prisma/client": "^7.8.0",
+ "@radix-ui/react-separator": "^1.1.16",
+ "nodemailer": "^6.9.4",
+ "next-auth": "^4.24.5"
// other deps …
}
}
Now Vercel runs npm run postinstall automatically, generating the client before the Next.js build starts.
2. Define the Prisma configuration
I created prisma.config.ts to load environment variables and expose a migration URL that works both locally and on Vercel:
// prisma.config.ts
import "dotenv/config";
import { defineConfig } from "prisma/config";
const migrationUrl = process.env.DATABASE_URL_UNPOOLED ?? process.env.DATABASE_URL;
if (!migrationUrl) {
throw new Error("DATABASE_URL is not defined");
}
export default defineConfig({
datasource: {
db: {
url: migrationUrl,
},
},
});
Vercel provides DATABASE_URL automatically for its PostgreSQL add‑on, while my local dev environment uses DATABASE_URL_UNPOOLED to avoid connection pooling during migrations.
3. Build the data model
The new schema.prisma file defines three core models: PcDevice, ChecklistItem, and AppValidation. The file is ~700 lines, but the essential parts are:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model PcDevice {
id String @id @default(cuid())
name String
serial String @unique
createdAt DateTime @default(now())
validations Validation[]
}
model ChecklistItem {
id String @id @default(cuid())
title String
description String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
}
model Validation {
id String @id @default(cuid())
pcDeviceId String
checklistId String
status String @default("pending")
notes String?
updatedAt DateTime @updatedAt
pcDevice PcDevice @relation(fields: [pcDeviceId], references: [id])
checklist ChecklistItem @relation(fields: [checklistId], references: [id])
}
4. Seed the catalog
To have a usable checklist out of the box, I added a seed script scripts/seed-catalog.mjs that runs via node scripts/seed-catalog.mjs:
// scripts/seed-catalog.mjs
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
async function main() {
const items = [
{ title: "\"OS Version\", description: "\"Check that OS is up to date\" },\""
{ title: "\"Antivirus\", description: "\"AV must be active and updated\" },\""
{ title: "\"Disk Encryption\", description: "\"BitLocker enabled\" },\""
];
for (const item of items) {
await prisma.checklistItem.upsert({
where: { title: "item.title },"
update: {},
create: item,
});
}
console.log("✅ Seed completed");
}
main()
.catch(e => console.error(e))
.finally(() => prisma.$disconnect());
Running this script locally populates the ChecklistItem table with three entries.
5. API routes – CRUD for apps, categories, and validations
All API endpoints live under src/app/api/... using the new Next.js 14 route handlers.
a. Admin apps – PATCH update
// src/app/api/admin/apps/[id]/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function PATCH(req: Request, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params;
const body = await req.json();
try {
const updated = await prisma.pcDevice.update({
where: { id },
data: body,
});
return NextResponse.json(updated);
} catch (error) {
return NextResponse.error();
}
}
b. Search endpoint
// src/app/api/search/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = searchParams.get("q") ?? "";
const results = await prisma.pcDevice.findMany({
where: {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ serial: { contains: q, mode: "insensitive" } },
],
},
});
return NextResponse.json(results);
}
c. Validation entries – nested route
ts
// src/app/api/validations/[id]/entries/[entryId]/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function DELETE(req: Request, ctx: { params: Promise<{ id: string; entryId: string }> }) {
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/appcheck-uhpcm` · 2026-10-09*
\#playadev #buildinpublic
Top comments (0)