0x55aa
← Back to Blog

Prisma ORM: Stop Writing Raw SQL and Finally Love Your Node.js Database Layer 🗄️

8 min read

Prisma ORM: Stop Writing Raw SQL and Finally Love Your Node.js Database Layer 🗄️

Real confession: The first time I read SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL AND status = 'active' in a Node.js codebase, I cried a little. On the inside. Coming from Laravel Eloquent — where that same query is User::active()->find($id) — raw SQL in Node.js felt like being handed a stone hammer after using a power drill. 😭

When I was building Node.js APIs at Acodez, my team was using Sequelize. It worked. Kind of. But it felt like writing boilerplate for sport. Models, migrations, associations — all defined in three different ways, in three different files, none of which agreed with each other.

Then I found Prisma. And the Node.js database layer finally made sense.

What Even Is Prisma? 🤔

Prisma is a next-generation ORM for Node.js and TypeScript. But calling it "just an ORM" is like calling a sports car "just a vehicle."

Here's what makes it different:

  • One schema file defines your entire database — models, relations, types
  • Prisma Client is fully type-safe and auto-completed in VS Code
  • Prisma Migrate handles migrations from your schema changes
  • Prisma Studio is a visual browser for your database (yes, seriously)

Coming from Laravel, think of it this way:

  • schema.prisma = your Eloquent models + create_users_table migration, combined
  • prisma migrate dev = php artisan migrate
  • prisma studio = Laravel Telescope's database tab on steroids

Setting It Up 🚀

npm install @prisma/client
npx prisma init

That creates a prisma/schema.prisma file. Open it and you'll feel right at home:

// prisma/schema.prisma

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

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())
}

Then run:

npx prisma migrate dev --name init
npx prisma generate

That's it. Your database is set up AND your TypeScript types are auto-generated. No separate type definitions, no manual model files — Prisma handles it all. 🤯

Querying Data: Where the Magic Happens ✨

In raw SQL or Sequelize (the old way):

// Sequelize - finding a user with posts
const user = await User.findOne({
    where: { id: userId, deletedAt: null },
    include: [{ model: Post, where: { published: true } }]
});

In Prisma (the sane way):

const prisma = new PrismaClient();

const user = await prisma.user.findUnique({
    where: { id: userId },
    include: {
        posts: {
            where: { published: true }
        }
    }
});

It reads like English. Your IDE autocompletes every field. If you typo psots instead of posts, TypeScript tells you immediately — before you even run the code. In Laravel, I was spoiled by this kind of feedback. In Node.js with Prisma, I finally got it back.

Comparisons That Made Me Smile 😄

Laravel Eloquent:

// Get all active users with their post count
$users = User::where('status', 'active')
    ->withCount('posts')
    ->orderBy('created_at', 'desc')
    ->get();

Prisma equivalent:

const users = await prisma.user.findMany({
    where: { status: 'active' },
    include: {
        _count: { select: { posts: true } }
    },
    orderBy: { createdAt: 'desc' }
});

Structurally similar. Both readable. Both type-checked. I didn't have to mourn Laravel Eloquent anymore!

Migrations: Finally Not Painful ⚡

When I was using Sequelize, migrations were a separate file, written manually, often out of sync with the model. When I was building Node.js APIs at Acodez, we had a running joke: "Are the migrations up to date? Check the model. Does the model match the DB? Who knows!" 😅

With Prisma, you edit the schema and run:

npx prisma migrate dev --name add_user_avatar

Prisma diffs your schema against the database and auto-generates the SQL migration. No manually writing ALTER TABLE. No forgetting to add an index. The source of truth is always schema.prisma.

Coming from Laravel: This is exactly how php artisan make:migration should work but even simpler — you don't write the migration yourself, Prisma generates it from your schema changes.

The N+1 Problem: Prisma Saves You 🛡️

The classic mistake when I was new to ORM usage (in both Laravel AND Node.js):

// 🔴 N+1 PROBLEM - Don't do this!
const users = await prisma.user.findMany();

for (const user of users) {
    const posts = await prisma.post.findMany({
        where: { authorId: user.id }
    });
    console.log(user.name, posts.length);
}
// This fires 1 query for users + N queries for posts
// 100 users = 101 database queries. Ouch!
// ✅ The Prisma way - single query with eager loading
const users = await prisma.user.findMany({
    include: { posts: true }
});
// ONE query. All data loaded. Done!

Prisma generates a single efficient JOIN query under the hood. Your database breathes a sigh of relief.

Transactions: Cleaner Than You'd Expect 💼

When I was building a payment flow at Acodez, we needed atomic transactions — either everything succeeds or nothing does. Prisma makes this elegant:

// Transfer credits between users atomically
const result = await prisma.$transaction(async (tx) => {
    const sender = await tx.user.update({
        where: { id: senderId },
        data: { credits: { decrement: amount } }
    });

    if (sender.credits < 0) {
        throw new Error('Insufficient credits');
    }

    const receiver = await tx.user.update({
        where: { id: receiverId },
        data: { credits: { increment: amount } }
    });

    return { sender, receiver };
});

If any line throws, the entire transaction rolls back. Compare this to writing manual BEGIN, COMMIT, ROLLBACK in raw SQL — night and day difference.

Coming from Laravel: It's the same mental model as DB::transaction(function() {...}). Familiar territory!

Common Mistakes to Avoid 🚨

Mistake #1: Creating a new PrismaClient on every request

// ❌ BAD - creates hundreds of connections!
app.get('/users', async (req, res) => {
    const prisma = new PrismaClient(); // New connection pool every request!
    const users = await prisma.user.findMany();
    res.json(users);
});

// ✅ GOOD - single instance, shared across app
const prisma = new PrismaClient();

app.get('/users', async (req, res) => {
    const users = await prisma.user.findMany();
    res.json(users);
});

One PrismaClient instance manages a connection pool for your entire application. Treat it like a singleton — create once, use everywhere.

Mistake #2: Returning the entire model to the client

// ❌ DANGEROUS - exposes password hash, internal fields!
const user = await prisma.user.findUnique({ where: { id } });
res.json(user); // Sends password, tokens, everything!

// ✅ SAFE - select only what the client needs
const user = await prisma.user.findUnique({
    where: { id },
    select: { id: true, name: true, email: true }
});
res.json(user);

Prisma's select is like Laravel's ->only() — cherry-pick the fields you want. Never blindly serialize database rows to JSON.

Mistake #3: Not handling "not found" cases

// ❌ findUnique returns null if not found - this crashes!
const user = await prisma.user.findUnique({ where: { id } });
res.json(user.name); // TypeError: Cannot read property 'name' of null

// ✅ Check for null
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
    return res.status(404).json({ error: 'User not found' });
}
res.json(user.name);

Prisma Studio: The Bonus Feature Nobody Talks About 🎁

npx prisma studio

This opens a beautiful web UI at localhost:5555 where you can browse your database, edit records, add test data — all without writing a single SQL query. When I was debugging APIs at Acodez, I used to keep pgAdmin open in one tab. Prisma Studio replaced that entirely.

Your Prisma Checklist ✅

Before you go live:

  • Single PrismaClient instance shared across the app
  • select or omit to avoid leaking sensitive fields
  • Transactions for multi-step operations
  • include for eager loading (avoid N+1 queries)
  • prisma.$disconnect() on app shutdown
  • Migrations committed to git (never run db push in production)
  • Connection pool sized correctly for your environment

The Bottom Line

When I was building Node.js APIs, the database layer was always the messiest part. Raw SQL was brittle, Sequelize was boilerplate-heavy, and TypeORM felt like Java in a JavaScript world.

Prisma changed that. It gave me the productivity I had in Laravel Eloquent — readable queries, auto-completed fields, clean migrations — but in Node.js. If you're still writing raw SQL or fighting with another ORM, give Prisma 30 minutes. You won't go back.

A pattern I use in every Express API now: prisma/schema.prisma as the single source of truth for the entire data layer — models, relations, validations, all in one place. Simple. Maintainable. Type-safe. 🚀


Building something with Prisma? Share it on LinkedIn — always happy to see what people are shipping!

More of my Node.js projects? Check out GitHub — the ORMs are cleaner now, I promise! 😄

P.S. — If you're still copy-pasting SQL strings into your Node.js app, this is your sign to stop. Prisma is free, open source, and will save your future self from a lot of 3 AM debugging sessions.