Prisma Client CRUD Fundamentals

Front
Back
Right
Left
Top
Bottom
REST API

Generating & Instantiating Prisma Client

Every backend application ultimately boils down to one thing: data. Creating it, reading it, updating it, deleting it. In this blog, we’ll master Prisma Client’s CRUD operations and wire them into a fully functional REST API. By the end, you’ll have a Users API that you’d actually be comfortable shipping.

INSTANCE

Generating & Instantiating Prisma Client

Setup in 60 Seconds​

After defining your schema and running migrations, generate Prisma Client:
💻
npx prisma generate
This generates type-safe client code in `node_modules/@prisma/client` based on your exact schema. Every model, field, and relation is reflected in the generated types.
SINGLETON
Critical for Express

The Singleton Pattern

In Express apps, each import of `PrismaClient` creates a new database connection pool. During development with hot-reload (nodemon), this causes connection exhaustion. The solution: a singleton.

📘
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

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

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}
In Express apps, each import of `PrismaClient` creates a new database connection pool. During development with hot-reload (nodemon), this causes connection exhaustion. The solution: a singleton.
 
Now import this `prisma` instance everywhere — <b>never</b> instantiate `PrismaClient` directly in your route files.
Pro Tip
This singleton pattern is recommended by the official Prisma docs for Next.js and Express apps: prisma.io/docs/guides/other/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices
CREATE

CREATE Operations

`create` — Single Record
📘
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

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

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'],
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}
`createMany` — Bulk Insert
📘
const result = await prisma.user.createMany({
  data: [
    { email: '[email protected]', name: 'Alice' },
    { email: '[email protected]', name: 'Bob' },
  ],
  skipDuplicates: true, // MySQL: skips rows that violate unique constraints
});
// result.count = number of inserted records
READ

READ Operations

`findUnique` — Exact Match (by `@id` or `@unique`)
📘
const user = await prisma.user.findUnique({
  where: { email: '[email protected]' },
});
// Returns: User | null
`findFirst` — First Match (any field)
📘
const user = await prisma.user.findFirst({
  where: { name: { contains: 'John' } },
  orderBy: { createdAt: 'desc' },
});
`findMany` — Multiple Records
📘
const users = await prisma.user.findMany({
  where: { active: true },
  take: 10,
  skip: 0,
  orderBy: { createdAt: 'desc' },
});
Performance

`findUnique` is faster than `findFirst` — it generates an indexed lookup. Always prefer `findUnique` when querying by `@id` or `@unique` fields. (prisma.io/docs/reference/api-reference/prisma-client-reference#findunique)

UPDATE

UPDATE Operations

`update` — Single Record
📘
const user = await prisma.user.update({
  where: { id: 1 },
  data: { name: 'Jane Doe' },
});
`updateMany` — Bulk Update
📘
const result = await prisma.user.updateMany({
  where: { active: false },
  data: { deletedAt: new Date() },
});
// result.count = number of updated records
`upsert` — Create or Update

One of Prisma’s most powerful methods — insert if not exists, update if it does:

📘
const user = await prisma.user.upsert({
  where: { email: '[email protected]' },
  update: { name: 'John Updated' },
  create: { email: '[email protected]', name: 'John New' },
});
DELETE

DELETE Operations

📘
// Delete single
await prisma.user.delete({ where: { id: 1 } });

// Delete many
await prisma.user.deleteMany({
  where: { createdAt: { lt: new Date('2020-01-01') } }
});
Warning
Never run `deleteMany()` without a `where` clause in production — it deletes ALL records. Always add application-level guards for destructive operations.
SELECTING

Selecting Specific Fields

By default, Prisma returns all scalar fields. Use `select` for performance and security:
📘
const user = await prisma.user.findUnique({
  where: { id: 1 },
  select: {
    id: true,
    email: true,
    name: true,
    // password: omitted — never returned to clients
  },
});
Excluding Sensitive Fields (Password, Tokens)
Build a reusable utility:
📘
const user = await prisma.user.findUnique({
  where: { id: 1 },
  select: {
    id: true,
    email: true,
    name: true,
    // password: omitted — never returned to clients
  },
});
STORAGE
Custom Storage

IndexedDB, Cookies, or Anywhere

Handling Complex Data Types

JSON.stringify can’t handle Maps, Sets, Dates, or RegExp. Solution: custom serialization. The Zustand GitHub documentation shows using superjson or similar libraries (serialize-javascript, devalue) for complex types.

📘
import superjson from 'superjson'
import { PersistStorage } from 'zustand/middleware'

interface ComplexState {
  userMap: Map<string, User>
  tags: Set<string>
  createdAt: Date
  pattern: RegExp
}

const storage: PersistStorage<ComplexState> = {
  getItem: (name) => {
    const str = localStorage.getItem(name)
    if (!str) return null
    return superjson.parse(str)
  },
  setItem: (name, value) => {
    localStorage.setItem(name, superjson.stringify(value))
  },
  removeItem: (name) => localStorage.removeItem(name),
}

const useComplexStore = create<ComplexState>()(
  persist(
    (set) => ({
      userMap: new Map(),
      tags: new Set(),
      createdAt: new Date(),
      pattern: new RegExp(''),
      // ... actions
    }),
    {
      name: 'complex-storage',
      storage,
    }
  )
)
MIGRATIONS
Handling Schema Changes

Migration

When you update your state structure, old persisted data can break your app. Migrations solve this:
📘
interface StateV1 {
  count: number
}

interface StateV2 {
  count: number
  multiplier: number // New field
}

const useStore = create<StateV2>()(
  persist(
    (set) => ({
      count: 0,
      multiplier: 1,
      increment: () => set((state) => ({
        count: state.count + state.multiplier
      })),
    }),
    {
      name: 'counter-storage',
      version: 2, // Increment when schema changes
      migrate: (persistedState: any, version: number) => {
        if (version === 1) {
          // Migrate from v1 to v2
          return {
            ...persistedState,
            multiplier: 1, // Add missing field
          }
        }
        return persistedState
      },
    }
  )
)
COMBINING
The Right Order

Combining Middleware

Middleware order affects behavior. General rule: devtools → persist → immer

📘
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

const useStore = create<State>()(
  devtools(           // 1. Outermost: debugging
    persist(          // 2. Middle: persistence
      immer(          // 3. Innermost: mutations
        (set) => ({
          user: { profile: { name: '' } },
          updateName: (name) => set((state) => {
            state.user.profile.name = name // Immer magic
          }),
        })
      ),
      { name: 'user-storage' }
    ),
    { name: 'UserStore' }
  )
)
Why this order?
PITFALLS

Common Pitfalls

Issue Problem Solution
Persisting Functions Don't try to persist actions. They can't be serialized. Use partialize to exclude them.
Large State Objects localStorage has ~5-10MB limit. Use IndexedDB for large data, or selectively persist.
Sensitive Data Never persist passwords or tokens in localStorage (XSS vulnerable). Use httpOnly cookies for sensitive auth data.

Data that doesn't persist is data that doesn't matter.

Werner Vogels, CTO of Amazon

Thank You for Spending Your Valuable Time

I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Front
Back
Right
Left
Top
Bottom
FAQ's

Frequently Asked Questions

Not directly with persist middleware. You'd need custom logic. Consider why you need both—usually one suffices.

Access `useStore.persist.clearStorage()`. Example: `useAuthStore.persist.clearStorage()` on logout.

Yes! Use AsyncStorage instead of localStorage: `storage: createJSONStorage(() => AsyncStorage)`.

The persist middleware will fail silently. Monitor storage usage and implement error handling via `onRehydrateStorage` callback.

Yes, via `useStore.persist.rehydrate()`. Useful for testing or manual sync scenarios.

Comments are closed