ThanhDev
  • Articles
  • Categories
  • Tags
  • About

ThanhDev

Frontend engineering notes on React, Next.js, TypeScript and software architecture.

  • Articles
  • Categories
  • Tags
  • About
  • GitHub
  • LinkedIn
  • Website
  • Email

© 2026 ThanhDev. Built with Next.js, Prisma and PostgreSQL.

  1. Home
  2. Articles
  3. Designing a Prisma Schema That Scales
Architecture

Designing a Prisma Schema That Scales

Relations, indexes and cascade rules are decisions you make once and live with for years. Here is how I approach them.

T

ThanhDev

Aug 15, 2026•2 min read•793 views

A Prisma schema is not just a type definition. It is a migration plan, an index strategy and a deletion policy all in one file.

Explicit join tables beat implicit ones

Prisma will happily manage a many-to-many for you:

prisma
model Post {
  tags Tag[]
}
 
model Tag {
  posts Post[]
}

This works right up until you need a column on the relationship — an ordering, a timestamp, who added it. Then you are writing a migration against a table you never named. Declare it yourself:

prisma
model PostTag {
  postId String
  tagId  String
 
  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
  tag  Tag  @relation(fields: [tagId], references: [id], onDelete: Cascade)
 
  @@id([postId, tagId])
  @@index([tagId])
}

The composite primary key gives you uniqueness for free and an index on postId. The extra @@index([tagId]) covers the other direction — without it, "all posts with this tag" is a sequential scan.

Index what you actually query

The rule is boring and reliable: look at your real where and orderBy clauses, then index those columns together in that order.

ts
const posts = await prisma.post.findMany({
  where: { status: "PUBLISHED" },
  orderBy: { publishedAt: "desc" },
});
prisma
@@index([status, publishedAt])

A composite index serves this query completely. Two separate single-column indexes do not — Postgres will pick one and sort the rest.

Decide what deletion means

onDelete is a product decision wearing a technical costume:

Rule Meaning
Cascade The child cannot exist alone. Join rows, sessions.
SetNull The child outlives the parent. A post keeps existing when its category is deleted.
Restrict Deleting the parent is a mistake. Block it.

Deleting a category should not delete a year of writing, so that relation is SetNull. Deleting a user should take their sessions with them, so that one is Cascade.

Select only what you need

include is convenient and quietly expensive. On a list endpoint, name the fields:

ts
const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    slug: true,
    excerpt: true,
    category: { select: { name: true, slug: true } },
    tags: { select: { tag: { select: { name: true, slug: true } } } },
  },
});

One query, no N+1, and no article bodies loaded to render a card that shows an excerpt.


None of this is clever. It is just deciding on purpose instead of by default.

  • #Prisma
  • #PostgreSQL
  • #TypeScript
Share
PreviousPostgreSQL Indexes Every Developer Should KnowNextTypeScript Discriminated Unions for Safer UI State

Related articles

  • Architecture

    Frontend Architecture: Layers That Earn Their Keep

    Repositories, services and actions are useful right up to the point where they become ceremony. Where I draw the line.

    • #TypeScript
    • #Prisma
    • #Next.js
    Jul 18, 2026•2 min read•401 views
  • Architecture

    PostgreSQL Indexes Every Developer Should Know

    B-tree, partial, composite and GIN — what each one is for, and how to tell whether the planner is using yours.

    • #PostgreSQL
    • #Performance
    Aug 8, 2026•2 min read•914 views
  • Architecture

    Measuring and Fixing Core Web Vitals

    LCP, CLS and INP explained through the fixes that actually move them, not the definitions you can already look up.

    • #Performance
    • #Next.js
    Jul 4, 2026•2 min read•594 views