TLDR: PostgREST's PGRST201 isn't just a loud crash — it can be a perfectly silent one. Here's how that happened to me, and the two-line sweep that catches it before it ships.

The Setup

I was deep in a late-night audit sprint on an ecommerce KOL CRM — a Supabase-backed Next.js app (Supabase is a hosted Postgres + REST API platform) that tracks key opinion leaders for an ecommerce business.

Earlier that week, migration 013_multi_kol_topic.sql had added webinar_kols and webinar_topics junction tables (many-to-many pivot tables) alongside the original webinars.kol_id and webinars.topic_id direct foreign key columns.

That's fine. That's normal schema evolution.

What I didn't fully register: PostgREST now saw two paths between webinars and topics — the old direct FK and the new junction table — and it was about to start getting confused.

The Night It Blew Up

The audit-remediation sprint on 2026-06-05 was going great. I was running a batch of unrelated migrations — RPCs, audit tables, RLS policies.

Then PostgREST reloaded its schema cache.

That is when the trouble started.

Three query sites in the internal CRM's src/ directory went broken simultaneously, all with the same error:

Could not embed because more than one relationship was found for 'webinars' and 'topics'

Two of them were loud:

  • src/queries/analytics.ts — the /analytics page 500'd behind its error boundary
  • src/queries/kol-detail.ts — the KOL activity list crashed when rendered

I caught those. Fixed them in minutes with a FK hint (more on that in a sec).

The third one? I almost missed it entirely.

The Silent One

src/queries/supply-chain.ts had this pattern:

const [{ data: upcomingWebinars }] = await Promise.all([...])

Spot the problem?

It destructures data and never checks .error. So when the embed failed with PGRST201, the function didn't throw. It just returned null for data.

The dashboard rendered: "Upcoming Webinars: 0."

No error dot. No red border. No console screaming. Five webinars were scheduled and the board looked perfectly healthy.

That one kept me up a little.

The Fix

PostgREST resource embedding — the select=*,topics(*) pattern — needs to know which foreign key to follow when more than one exists.

Three forms work:

// By FK constraint name (for embeds through the legacy direct FK)
topics:topics!webinars_topic_id_fkey(...)

// By FK column name (for 1:1 extension tables — cleaner)
details:kol_details!contact_id(...)

// !inner makes the join INNER — it does NOT disambiguate on its own

One thing I got wrong at first: I thought !inner was a disambiguator. It's not. It's a join modifier. On a table with two relationships, !inner can still fail — or fail silently in nested embeds. Use the column name or constraint name form.

Two Things You Should Carry Away

1. A schema-cache reload can break embeds that were working fine.

A migration that touches nothing about two tables can still break their embeds — PostgREST re-derives the relationship graph and starts enforcing ambiguity that used to auto-resolve. After any migration on a project with junction+singular-FK pairs, do this sweep:

grep -rn ':topics\|:contacts\|:traffic_sources' src/ | grep -v '_fkey'

Adjust the table names. Any hit that lacks a _fkey or column hint is a latent PGRST201 waiting to fire.

2. Always check .error on destructured Supabase responses.

That pattern of const [{ data }] = await Promise.all([...]) is everywhere. It compiles. It runs. It silently swallows failures. The version that doesn't lie:

const [{ data: upcomingWebinars, error }] = await Promise.all([...])
if (error) throw error

One line. Loud where it needs to be loud.

The embed error is never the scary part. The scary part is when it doesn't make a sound.