Practical runbook organized by symptom. Each entry follows the format: Symptom / Cause / Fix.
Cause: proxy.ts checks tenant_users membership. If the user is authenticated but not a member of the current tenant, they get redirected to /join-school, which may redirect back to login.
Fix: Verify the user has a row in tenant_users for the current tenant. Check proxy.ts route guards and ensure /join-school is in the public routes list.
Cause: JWT claims (tenant_role, tenant_id) are set at token issuance time by custom_access_token_hook(). Switching tenants does not automatically refresh them.
Fix: Call supabase.auth.refreshSession() immediately after any tenant switch.
Cause: getUserRole() checks the tenant_users table first, falling back to user_roles. If tenant_users has an unexpected role (or no row), you get a wrong or missing role.
Fix: Query tenant_users directly to verify the user's role for the current tenant. Do not rely solely on JWT claims.
Cause: The user is missing a row in user_roles with role = 'admin', or is missing from tenant_users for the current tenant.
Fix:
-- Check role exists
SELECT ur.role, au.email FROM user_roles ur
JOIN auth.users au ON au.id = ur.user_id
WHERE au.email = 'admin@test.com';
-- Add if missing
INSERT INTO user_roles (user_id, role)
SELECT id, 'admin' FROM auth.users WHERE email = 'admin@test.com'
ON CONFLICT (user_id, role) DO NOTHING;Cause: The handle_new_user() trigger on auth.users auto-creates profiles and user_roles rows. If users were created via raw SQL (not auth.signUp()), the trigger won't fire.
Fix: Manually insert rows into profiles, user_roles, and auth.identities. Use NULL for phone (unique constraint).
Cause: proxy.ts must set the x-tenant-id header on the request so downstream components can read it via getCurrentTenantId().
Fix: Verify proxy.ts is extracting the subdomain correctly and injecting x-tenant-id. In local dev, set NEXT_PUBLIC_PLATFORM_DOMAIN=lvh.me:3000 and access via school.lvh.me:3000.
Cause: A query is missing the tenant_id filter. RLS provides a safety net, but explicit filters are required for clarity and performance.
Fix: Add .eq('tenant_id', tenantId) to every query on tenant-scoped tables. Audit with:
const tenantId = await getCurrentTenantId()
const { data } = await supabase.from('courses').select('*').eq('tenant_id', tenantId)Cause: Missing RLS INSERT policy on tenant_users that allows users to add themselves as students.
Fix: Ensure this policy exists:
CREATE POLICY "Users can join schools as student" ON tenant_users
FOR INSERT WITH CHECK (
auth.uid() = user_id AND role = 'student' AND status = 'active'
);Cause: The tenants table has no row with a matching slug, or the slug doesn't match the subdomain.
Fix: Check SELECT slug FROM tenants and compare against the subdomain being used. In dev, pass x-tenant-slug header to simulate a subdomain.
Cause: The column is user_id, not student_id. Querying by student_id silently returns nothing.
Fix: Use .eq('user_id', userId) on lesson_completions.
Cause: A course can belong to multiple products, so product_courses can have multiple rows per course. .single() throws when more than one row is returned.
Fix: Never use .single() on product_courses. Use .select() and handle the array.
Cause: The profiles table has no email column. Email lives in auth.users.
Fix: Get email via the admin client:
const adminClient = createAdminClient()
const { data } = await adminClient.auth.admin.getUserById(userId)
const email = data.user?.emailCause: Importing from the wrong path.
Fix: Import from @/lib/supabase/admin, NOT @/lib/supabase/server.
Cause: certificates has two foreign keys to profiles (user_id and revoked_by). PostgREST can't infer which join to use.
Fix: Use the FK hint: .select('*, profiles!certificates_user_id_fkey(*)').
Cause: The reviews table uses entity_type/entity_id (not course_id), and review_text (not content).
Fix:
.from('reviews')
.select('review_id, entity_id, user_id, rating, review_text, created_at')
.eq('entity_type', 'courses')
.eq('entity_id', courseId)Cause: Missing RLS INSERT policy for authenticated users. Fix: Ensure this policy exists:
CREATE POLICY "Students can mark lessons complete" ON lesson_completions
FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);Cause: The table uses name (not title) and is_available (not is_active).
Fix: Update queries to use the correct column names: name and is_available.
Cause: The project uses @base-ui/react Button, which does not have an asChild prop (unlike Radix).
Fix: Wrap <Link> around <Button> instead:
<Link href="/path"><Button>Click me</Button></Link>Cause: Base-ui Select can pass null to onValueChange when deselecting.
Fix: Add a null guard: onValueChange={(v) => v && handler(v)}.
Cause: Base-ui Accordion does not accept type or collapsible props.
Fix: Use plain <Accordion className="..."> with <AccordionItem value={String(idx)}>.
Cause: Same base-ui issue. Use the render prop pattern instead.
Fix: <DropdownMenuTrigger render={<Button>...</Button>} />.
Cause: Base-ui uses render prop, not asChild.
Fix: <BreadcrumbLink render={<Link href="..." />}>text</BreadcrumbLink>.
Cause: crypto.randomUUID() is only available in secure contexts (HTTPS). Local dev on HTTP will fail.
Fix: Use nanoid() instead. This is a dependency-level issue in BlockEditor files.
Cause: Native modules like canvas require system libraries that aren't present in Alpine by default.
Fix: Add to the deps stage in Dockerfile:
RUN apk add --no-cache libc6-compat python3 make g++ pkgconfig \
cairo-dev pango-dev jpeg-dev giflib-dev librsvg-dev pixman-devAnd to the runner stage (runtime libs only):
RUN apk add --no-cache cairo pango libjpeg-turbo giflib librsvg pixmanCause: NEXT_PUBLIC_* variables are inlined at build time by Next.js. They must be available during npm run build.
Fix: Pass them as Docker build args:
docker build \
--build-arg NEXT_PUBLIC_SUPABASE_URL=https://... \
--build-arg NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY=... \
--build-arg NEXT_PUBLIC_PLATFORM_DOMAIN=... \
--build-arg NEXT_PUBLIC_APP_URL=... \
--build-arg NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... \
.Cause: Next.js standalone output must be explicitly enabled.
Fix: Ensure next.config.ts includes output: 'standalone'.
Cause: Both proxy.ts and middleware.ts exist, causing conflicts. proxy.ts is the only middleware file.
Fix: Delete middleware.ts if it exists. Only proxy.ts should handle middleware logic.
Cause: The status value is 'successful', not 'succeeded' (common Stripe terminology mismatch).
Fix: Use .eq('status', 'successful') in all transaction queries.
Cause: The enroll_user() RPC may have failed silently, or it was setting status = NULL instead of 'active'.
Fix: Verify the enroll_user() RPC sets status = 'active' and inherits tenant_id from the product. Check migration fix_enroll_user_rpc_status_and_tenant is applied.
Cause: A partial unique index exists on (user_id, product_id, plan_id) WHERE status IN ('pending', 'successful'). A pending or successful transaction blocks retries.
Fix: Cancel or archive the existing pending transaction before retrying, or handle the unique constraint error gracefully.
Cause: There are two separate webhooks with different secrets. Using the wrong secret causes signature verification to fail. Fix:
| Webhook | Route | Env var |
|---|---|---|
| Student payments (Connect) | /api/stripe/webhook |
STRIPE_WEBHOOK_SECRET |
| School billing (Platform) | /api/stripe/platform-webhook |
STRIPE_PLATFORM_WEBHOOK_SECRET |
Cause: Enrollments require either product_id OR subscription_id — not both, not neither.
Fix: Ensure exactly one of product_id or subscription_id is set when creating an enrollment.
Cause: Running tests with multiple parallel workers overloads the dev server. These are not actual bugs.
Fix: Run the failing test individually: npx playwright test -g "test name". Or reduce workers: --workers=1.
Cause: Subdomain routing requires NEXT_PUBLIC_PLATFORM_DOMAIN=lvh.me:3000 and access via http://school.lvh.me:3000.
Fix: Set NEXT_PUBLIC_PLATFORM_DOMAIN=lvh.me:3000 in .env.local and use lvh.me (which resolves to 127.0.0.1) for local testing.
Cause: Users created via raw SQL don't trigger handle_new_user(), so profiles and user_roles rows may be missing.
Fix: Manually insert into profiles, user_roles, and auth.identities. See the seed file for examples.
Cause: isSuperAdmin() queries the super_admins table directly via admin client. It does NOT trust JWT claims.
Fix: Verify the user has a row in super_admins:
SELECT * FROM super_admins WHERE user_id = '<user-uuid>';| Table | Wrong | Correct |
|---|---|---|
lesson_completions |
student_id |
user_id |
exam_submissions |
submitted_at |
submission_date |
exam_submissions |
user_id |
student_id |
transactions |
'succeeded' |
'successful' |
reviews |
content |
review_text |
reviews |
course_id |
entity_id (+ entity_type = 'courses') |
gamification_store_items |
title |
name |
gamification_store_items |
is_active |
is_available |
profiles |
email |
(does not exist — use auth.users) |
courses |
slug |
(does not exist — use course_id) |
| Need | Correct Import |
|---|---|
| Server component Supabase client | createClient() from @/lib/supabase/server |
| Client component Supabase client | createClient() from @/lib/supabase/client |
| Admin client (bypass RLS) | createAdminClient() from @/lib/supabase/admin |
| Tenant context | getCurrentTenantId() from @/lib/supabase/tenant |
| User role | getUserRole() from @/lib/supabase/get-user-role |
| Plan features (hook) | usePlanFeatures() from @/lib/hooks/use-plan-features |
Last updated: March 1, 2026