惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Jina AI
Jina AI
T
The Blog of Author Tim Ferriss
B
Blog
L
LangChain Blog
Y
Y Combinator Blog
美团技术团队
博客园 - 三生石上(FineUI控件)
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
G
Google Developers Blog
量子位
博客园_首页
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
C
Check Point Blog
D
Docker
小众软件
小众软件
The Cloudflare Blog
大猫的无限游戏
大猫的无限游戏
T
Tailwind CSS Blog
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 聂微东
Blog — PlanetScale
Blog — PlanetScale
GbyAI
GbyAI
Google DeepMind News
Google DeepMind News
IT之家
IT之家

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Prisma Server Actions in Next.js 16: the patterns that wo...
Juan Torchia · 2026-05-18 · via DEV Community

Prisma Server Actions in Next.js 16: the patterns that work and the N+1 that sneaks up on you

Next.js 16 shipped recently with App Router improvements and Server Actions stabilized as a first-class primitive. The community is adopting Server Actions as the natural replacement for API routes on mutations. The migration looks obvious — less boilerplate, co-location with the component, shared types between client and server. I started moving in that direction too. And somewhere along the way I ran into an N+1 that didn't come from Prisma: it came from how I was composing the Actions.

My thesis is this: Prisma ORM 5 doesn't introduce N+1 in Server Actions. Action composition does — the pattern of calling multiple independent Actions from the same component, or chaining them without collapsing the queries. It's an architecture problem, not an ORM problem. And it has a solution, but you have to know where to look.


Classic N+1 vs. composition N+1 in Server Actions

The classic N+1 with Prisma is well-known: you iterate over a list and fire a separate query for each item because you forgot the include. The official Prisma docs on query optimization cover it precisely — use include or select with nested relations, or for more complex cases, findMany with relational filters instead of queries in a loop.

The composition N+1 in Server Actions is different. It doesn't show up inside the body of a single Action — it shows up when the component calls multiple Actions in sequence or in parallel, and each Action opens its own connection with its own Prisma cursor. Under SSR load, that becomes connection pool pressure that never appears in local tests.

Look at this problematic pattern:

// app/dashboard/page.tsx
// ⚠️ Problematic pattern: three independent Actions
// each one opens its own connection to the pool

import { getUserProfile } from "@/actions/user"
import { getRecentOrders } from "@/actions/orders"
import { getNotifications } from "@/actions/notifications"

export default async function DashboardPage() {
  // Three separate round-trips, three pool connections
  const profile = await getUserProfile()
  const orders = await getRecentOrders()
  const notifications = await getNotifications()

  return <Dashboard profile={profile} orders={orders} notifications={notifications} />
}

Enter fullscreen mode Exit fullscreen mode

Each of those Actions has its own prisma.user.findUnique, its own prisma.order.findMany, its own prisma.notification.findMany. Three queries that could be resolved with a single well-designed call — or at minimum with Promise.all to parallelize them.


The connection pool under SSR load

Prisma uses an internal connection pool. In Next.js App Router with SSR, each request can fire multiple Server Actions in the same render. If every component on the page calls its own Action, the pool receives a short but intense burst of connections per user visit.

The most common pattern that generates this problem is using prisma as a global singleton alongside PrismaClient instantiated in each separate module. Prisma's documentation explicitly recommends using a singleton instance in serverless and SSR environments:

// lib/prisma.ts
// Singleton pattern recommended by Prisma for Next.js
// Source: https://www.prisma.io/docs/orm/prisma-client/queries/query-optimization-performance

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

Enter fullscreen mode Exit fullscreen mode

If you skip this pattern, every hot reload in development — and potentially every cold start in production with some providers — can instantiate a fresh PrismaClient with its own pool. The result: exhausted connections with no obvious warning in the logs.


The patterns that work: collapsing queries into a single Action

The antidote to the composition N+1 is simple to state but requires discipline: one Action per use case, not one Action per entity. Instead of three independent Actions for the dashboard, one single Action that groups the three queries with Promise.all:

// actions/dashboard.ts
// ✅ Correct pattern: one Action that collapses the queries
// Promise.all for real parallelism within the same connection

"use server"

import { prisma } from "@/lib/prisma"
import { auth } from "@/lib/auth"

export async function getDashboardData() {
  const session = await auth()
  if (!session?.user?.id) throw new Error("Not authenticated")

  const userId = session.user.id

  // Single pool invocation — three queries in parallel
  const [profile, orders, notifications] = await Promise.all([
    prisma.user.findUnique({
      where: { id: userId },
      select: { name: true, email: true, avatarUrl: true },
    }),
    prisma.order.findMany({
      where: { userId, createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } },
      orderBy: { createdAt: "desc" },
      take: 10,
    }),
    prisma.notification.findMany({
      where: { userId, read: false },
      orderBy: { createdAt: "desc" },
      take: 5,
    }),
  ])

  return { profile, orders, notifications }
}

Enter fullscreen mode Exit fullscreen mode

The difference isn't just about queries — it's about design. An Action that groups data for a specific use case is easier to cache, easier to test, and more honest about what problem it's actually solving.


The forgotten include and the query that multiplied

The classic N+1 still lives inside Actions. If you iterate over results and fire a nested query per item, Prisma isn't going to save you — that's on you. The most frequent pattern I see in codebases just starting with Server Actions:

// ⚠️ Classic N+1 inside an Action
// One query per order to fetch the product

"use server"

import { prisma } from "@/lib/prisma"

export async function getOrdersWithProducts(userId: string) {
  const orders = await prisma.order.findMany({ where: { userId } })

  // ❌ N+1: one query per order
  const ordersWithProduct = await Promise.all(
    orders.map(async (order) => {
      const product = await prisma.product.findUnique({
        where: { id: order.productId },
      })
      return { ...order, product }
    })
  )

  return ordersWithProduct
}

Enter fullscreen mode Exit fullscreen mode

The correct fix is to collapse with include:

// ✅ Correct include: a single query with implicit JOIN
// Prisma collapses everything into a single round-trip

"use server"

import { prisma } from "@/lib/prisma"

export async function getOrdersWithProducts(userId: string) {
  return prisma.order.findMany({
    where: { userId },
    include: {
      product: {
        select: { name: true, price: true, imageUrl: true },
      },
    },
    orderBy: { createdAt: "desc" },
    take: 20,
  })
}

Enter fullscreen mode Exit fullscreen mode

The select inside the include matters: you're not pulling the full product object, you're pulling exactly the fields the component needs. That reduces the serialized payload Next.js has to transfer between server and client.


Real gotchas: what the 15-minute tutorial doesn't cover

"use server" doesn't guarantee automatic serialization of Prisma errors. If an Action throws a PrismaClientKnownRequestError (say, a constraint violation), that error doesn't reach the client the way you'd expect in all cases. You need to wrap with try/catch and serialize the error explicitly:

// actions/user.ts
// Explicit Prisma error handling in Server Actions

"use server"

import { prisma } from "@/lib/prisma"
import { Prisma } from "@prisma/client"

export async function createUser(data: { email: string; name: string }) {
  try {
    return await prisma.user.create({ data })
  } catch (error) {
    // Unique constraint violation (P2002 in Prisma)
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === "P2002") {
        return { error: "That email is already registered" }
      }
    }
    // Unexpected error: log it, don't expose it
    console.error("[createUser]", error)
    return { error: "Internal error. Please try again." }
  }
}

Enter fullscreen mode Exit fullscreen mode

Query logging in development is your best diagnostic tool. The singleton above already includes log: ["query"] in development — that lets you see exactly how many queries each render fires. If you see the same SELECT repeated N times in the terminal, you have an N+1 and you can attack it before it hits production.

Server Actions and React 19 useOptimistic can mask the problem. If you use useOptimistic to update the UI before the Action resolves, perceived latency drops — but the queries are still there. Don't confuse improved UX with optimized queries.

This connects to something I already documented when looking at how OpenTelemetry in Spring Boot reveals the real problem when the log says OK: observability surface matters. In Next.js 16, if you don't have query traces, the Action log can look healthy while queries multiply underneath.


FAQ: Prisma Server Actions Next.js 16 N+1

Why does N+1 appear in Server Actions when it didn't in my API routes?
In API routes, the natural pattern was one route = one handler = one query. In Server Actions, co-location with the component invites you to create one Action per entity, and components end up calling several Actions in the same render. That composition generates multiple round-trips that never existed in an API route because the query was centralized.

Does Prisma ORM 5 have any mechanism to automatically detect N+1?
Not automatically at runtime, but you can enable query logging (log: ["query"]) to see them in development. There are community proposals for a native N+1 detector, but as of this post it's not a stable feature. The official optimization docs document the patterns to avoid, but detection is still manual or via external tooling.

How many PrismaClient instances should I have in a Next.js 16 project?
One. Using the singleton pattern with globalThis. More than one instance means more than one connection pool, which under SSR load can exhaust available database connections. This is especially critical on serverless providers where each function can have its own process.

Is Promise.all inside an Action enough to fix the pool problem?
For multiple independent queries inside a single Action, yes: Promise.all parallelizes them within the same invocation and the pool handles a single connection (or the minimum needed). What Promise.all does not fix is when you have multiple independent Actions fired from different components in the same render — that needs consolidation at the architecture level.

How does this affect Next.js 16 caching?
Next.js 16 has Data Cache and Full Route Cache. If you use fetch or unstable_cache, you can cache the result of a Server Action. But the N+1 happens before the cache — if the Action isn't cached (mutations, data with no-store), every request executes the queries. The right pattern is to cache the entire Action with unstable_cache when the data allows it, not to cache individual queries inside it.

Does this pattern also apply to Prisma with pure Server Components (no Actions)?
Yes, but with a difference: in Server Components without Actions, queries live directly in the component and Next.js can do component-level caching more easily. The composition problem is more acute with Server Actions because the mental model of "one Action = one button or form" leads to excessive granularity that multiplies round-trips.


What I'm keeping and what I'm not buying

I'm keeping this pattern: one Action per use case, not one Action per entity. That's the most important mindset shift when migrating from API routes to Server Actions with Prisma.

What I'm not buying is the narrative that Server Actions automatically simplify the data model. They simplify the boilerplate — shared types, no explicit endpoint — but the responsibility to not multiply queries is still yours. If you were coming from API routes where one route = one well-considered query, jumping to Actions can lead to query sprawl that's actually worse.

The honest trade-off: Server Actions win on DX and co-location. They lose on visibility into which queries fire per render if you don't have logging active. Before deploying any page with multiple Actions, pull up the dev terminal with log: ["query"] running and count how many SELECTs appear per render. If the number surprises you, you have work to do.

This connects directly to what I documented in Prisma vs JDBC: the benchmark that almost made me blame the wrong ORM — the ORM is rarely the problem. Query shape is. And in Next.js 16 with Server Actions, shape is defined by the Action architecture, not by Prisma.

For those coming from the Spring Boot world, there's an interesting parallel with retry budget and amplification: every abstraction that looks like a simplification introduces its own amplification vector. In Server Actions, that vector is granular query composition.


Sources:


This article was originally published on juanchi.dev