Ask
74
@listener_lena ·

server action returns a prisma row and next throws "only plain objects can be passed" Serialization

Next 15.4, app router. The action is four lines:

'use server'
export async function getInvoice(id: string) {
  return prisma.invoice.findUnique({ where: { id } })
}

A client component calls it inside a transition and I get Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported. It names the total field. If I log the object on the server it looks completely ordinary. What is it actually objecting to?

9 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @layer_shift_lu · 4h ago

    Write the mapper by hand and stop treating actions as "return the row". I keep a toInvoiceDTO(row) next to the action and a zod schema for the return type. The schema costs ten lines and gives you an actual contract — when someone adds a column with a weird type the parse fails in a test, not as a runtime warning in a browser someone else is using.

    43
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @query_quinn · 3h ago · 3 replies

    total is a Prisma.Decimal, which is a decimal.js instance, which is a class with a prototype. The RSC serializer takes plain objects, arrays, primitives, Date, Map, Set, BigInt, typed arrays and promises. Class instances are not on that list, and it stops at the first one it finds.

    Map it at the boundary instead of returning the model:

    const inv = await prisma.invoice.findUnique({ where: { id }, select: { id: true, total: true, dueAt: true } })
    return { id: inv.id, total: inv.total.toString(), dueAt: inv.dueAt }
    

    The explicit select is doing double duty here — it stops you accidentally shipping stripeCustomerId to the browser next month.

    91
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @ines_marchetti · 3h ago

      Second the toString() and not toNumber(). Decimal exists precisely because 1234.56 is not representable, and toNumber() hands you 1234.5600000000001 on some values. If you own the schema, store minor units as an integer and format at render. Money in floats is a slow-motion incident.

      34
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @listener_lena · 3h ago

      It was total and amountDue, both Decimal. Fixed those and then hit the same error on a Bytes column holding a pdf, which comes back as a Buffer. Mapping the whole row to a DTO now.

      15
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @crypt_cass · 3h ago

    Worth saying out loud: that action is a public HTTP endpoint. Anyone who can guess an id can POST to it. Serialization is the smaller of the two problems in that snippet — there is no auth() call and no check that the invoice belongs to the caller.

    38
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @mise_en_mess · 3h ago · 2 replies

    Dates don't cross the boundary either. I gave up and put JSON.parse(JSON.stringify(row)) in a helper, everything is plain after that and I never think about it again.

    29
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @peer_review_pat · 3h ago

      Dates are supported, that is the one built-in people always assume is not. What your helper actually does is silently turn every Date into a string, drop every undefined key, and flatten Map and Set into {} — and it hides the offending field so the next person cannot find it. It works because it destroys the information, not because it fixes anything.

      26
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @migration_marek · 3h ago

    If you're going to hit this on every model, a client extension does it once:

    prisma.$extends({ result: { invoice: { total: { needs: { total: true }, compute: (i) => i.total.toString() } } } })
    

    Slightly magic, but better than remembering in 30 call sites.

    22
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @slowmiles_kat · 3h ago

    The error names the field, which is more help than it gets credit for. In practice it is always Decimal, Buffer, or a class instance somebody snuck into a shared type.

    11
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report