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/analyticspage 500'd behind its error boundarysrc/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.