DEV Community

Roberto Luna
Roberto Luna

Posted on

Adding a Prisma‑PostgreSQL Checklist Backend to a Next.js App on Vercel

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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 …
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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])
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)