Repository navigation
Handle requests for nonexistent organizations - #268
Merged
Merged
Conversation
A failed organization config request was parsed and rendered as though it were valid configuration, and cached for five minutes, so a visitor on a subdomain with no matching organization got a page that looked broken with no explanation. - Return 404 rather than 400 for a missing organization, so missing, malformed and server-error are distinguishable by status alone - Check res.ok before parsing, and never write a non-ok response to the cache - Add an additive status field to useOrganizationConfig so callers can tell "no such nonprofit" apart from a network or server failure - Bump the config cache key prefix to retire entries poisoned by the old bug - Render a shared, unbranded OrganizationNotFound on the four auth pages
The hook tests covered every status, but nothing asserted the four auth pages actually render OrganizationNotFound -- deleting the early return from a page left the suite green. Covers all four pages against not-found, ok and error.
✅ Deploy Preview for servicestart ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
This was referenced Sep 14, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #252
Description
When a visitor loads the app on a subdomain that has no matching
organizationsrow, they currently get a page that looks broken rather than one that tells them anything. The root cause is inuseOrganizationConfig: it calledres.json()unconditionally, so the API's{ error: "..." }body was merged into config as if it were valid values. That error string then passed thetypeof value === "string"cache guard and was written tolocalStoragewith a live 5-minute TTL, and a bare.catch()made a network failure indistinguishable from a successful fetch.The fix has two halves.
The load-bearing half is the hook: a non-ok response is never parsed as config, never cached, and is now reported through an explicit
statusfield so callers can tell "this nonprofit doesn't exist" apart from "something went wrong". This outlives the ticket — the same swallow-and-render behavior would otherwise recur on a 500, a cold start, or a malformed body.The visible half is a single shared, unbranded
OrganizationNotFoundcomponent rendered by all four unauthenticated pages. It echoes the address back, because seeingacmespelled out is what makes a person notice they meantacmefoundation. It deliberately has no login form — if there's a box, people type in it, and they're back at the same dead end, trying passwords and waiting for a reset email that will never arrive. It is unbranded on purpose: a missing organization has no branding, and rendering the default palette for one is exactly the failure this fixes.A note on scope: this is intentionally not Next.js
notFound()/ middleware-based tenant resolution. That is the architecturally right home for this and it is deferred, not rejected — there is nomiddleware.tsand noapp/not-found.tsxto extend, andgetSlugFromHostmapswww.servicestart.com→"www"andapi.…→"api", so a naive resolve-or-404 at the routing layer would 404 the marketing site. It needs an owner and an allowlist, not a drive-by.This also gets more important after #253/#262 merges. Today a missing org at least looks broken. Once branding defaults land, it will look like a clean, correctly-branded login page for a nonprofit that does not exist — a convincing fake is worse than an obvious error.
Changes Made
api/organizationConfig.ts— a missing organization now returns404, not400. Genuinely malformed requests keep400, so missing / bad-request / server-error are distinguishable by status alone. The derived-host branch is intentionally untouched: a missing org there still falls back to the session's active organization, which is real behavior for a signed-in user, and the hook never takes that path.lib/hooks/useOrganizationConfig.ts— checksres.okbefore parsing; adds an additivestatus: "ok" | "not-found" | "error"field (the return type is not reshaped, so all five consumers — includingcomponents/navigation/Logo.tsx— keep working untouched); moves the cache write inside the ok-path; replaces the bare.catch(). Also bumps the cache key prefix toorg-config:v2— existing users are holding poisoned{error: "..."}entries with live TTLs that the fix alone would not evict.components/OrganizationNotFound.tsx(new) — one shared unbranded component, so copy and styling live in exactly one place.app/login,app/signup,app/forgotpassword,app/resetpassword— one additive early return each, below the hooks. Branding defaults are left intact so this merges cleanly alongside fix(branding): share org color defaults across auth pages #262.tests/unit/app/authpages/organizationNotFound.test.tsx) renders all four pages against each status and asserts the not-found state replaces the form when the org is missing, and does not appear onokorerror— so a server error or dropped connection can never tell a visitor their nonprofit does not exist.tests/e2e/login.spec.ts/signup.spec.ts— addedensureServicestartOrganization()inbeforeAll. These specs hitlocalhost, which resolves to theservicestarttenant; that org wasn't guaranteed to exist, and the old swallowed400was hiding it. With a real404it would have surfaced as a confusing failure in these specs.Screenshots + Videos
The fix
Video — nonexistent organization across all four pages:
after-not-found.mp4
After — organization does not exist. One shared unbranded page, on every route, naming the address the visitor actually typed:
The request is now a clean 404 and is never treated as config:


·
Before — the same four pages. Placeholder branding, no explanation, and a login form that will never work:
The failed request, parsed and rendered as though it were valid config:


·
Organizations that do exist are unaffected
Exists, no branding rows — renders normally on the server's defaults, not the not-found state. This is the case that separates "missing" from "unstyled":
Exists and branded — unchanged:
Known limitation: a brief flash before the not-found state
The four pages are client components, so they render their defaults during SSR until the config request resolves, then swap to the not-found state. This is visible in the video. Removing it requires resolving the tenant on the server, which is out of scope here (see below).
Known limitation: no e2e coverage for the not-found state
lib/api.tsbuilds the Hono RPC client againstgetBaseUrl()(NEXT_PUBLIC_BASE_URL), so every tenant's API calls go to one fixed origin. Loading a page on any other host makes that call cross-origin, and with no CORS headers the browser blocks it before the 404 is readable — the hook correctly sees a network error, not "not-found". That makes the not-found state reachable in a browser only whenNEXT_PUBLIC_BASE_URLmatches the host being visited, which a single e2e build cannot do for both localhost and a missing tenant.The state is covered by unit tests on
useOrganizationConfig(all four status paths) and verified manually per the steps below. Resolving per-tenant API origins is a separate concern from this ticket and worth its own issue — it affects every API call in the app, not just organization config.Testing Steps
NEXT_PUBLIC_BASE_URLmust match the host you visit (see the limitation above), so each case needs its own dev server. Prefix the variable rather than editing.env. RunlocalStorage.clear()and reload on each host before checking it.NEXT_PUBLIC_BASE_URL=http://doesnotexist.lvh.me:3000 pnpm dev, then visithttp://doesnotexist.lvh.me:3000/login,/signup,/forgotpassword,/resetpassword. Each shows the unbranded not-found state namingdoesnotexist.lvh.me:3000, with no login form anywhere. Network:/api/organizationConfig→ 404. Console:Object.keys(localStorage)→ empty, i.e. the failed response was never cached.NEXT_PUBLIC_BASE_URL=http://horizontal-left.lvh.me:3000 pnpm dev, visithttp://horizontal-left.lvh.me:3000/login. Renders normally with the server's default palette — not the not-found state. Network: 200, body{"primary_color":"#FD8033","secondary_color":"#FB3552"}. This is the case that separates "missing" from "unstyled".NEXT_PUBLIC_BASE_URL=http://vertical-icon.lvh.me:3000 pnpm dev, visithttp://vertical-icon.lvh.me:3000/login— blue/purple gradient, tagline "Welcome to Vertical Icon Org". Unchanged from before this PR.localStoragestays clean — a flaky network must never tell a visitor their nonprofit does not exist.pnpm lintandpnpm test.