A GET endpoint inserting rows into a production database.
A notification bell hammering an API with requests before the user even signed in.
Failed API calls silently showing up as empty states.
A frontend component expecting { doubts, pagination } from an API that returned a plain array.
Four separate bugs. One codebase. All found in the same sitting.
This is a writeup of everything I found and fixed in DoubtDesk — an anonymous, AI-powered doubt-solving platform built for students to ask questions without fear and get instant answers. TypeScript, Next.js, Clerk auth, and a Postgres database on the backend.
I'm writing this because every one of these bugs is something you will build yourself at some point. Not because you're a bad developer — because they're easy to miss and nobody teaches you to look for them.
The one that actually surprised me
I'll start with the worst one. Not the most complex. Just the one that made me go back and re-read the code twice because I thought I was misreading it.
src/app/api/notifications/test-seed/route.ts
// the exported handler
export async function GET(request: Request) {
const { userId } = auth()
// ...
await db.insert(notificationsTable).values(dummyNotifications)
return NextResponse.json({ success: true })
}
A GET handler. Doing a database insert.
GET /api/notifications/test-seed — a standard HTTP GET request — was inserting dummy notification rows for whatever signed-in user hit the endpoint. No NODE_ENV check. No dev-only gate. Live in normal app builds, accessible in production.
What that means in practice:
- Open the URL in a browser — inserts data
- Refresh the page — inserts more data
- A crawler or bot visits the URL — inserts data
- Someone shares the link — inserts data for everyone who clicks it
Every visit created new notification rows. No confirmation. No intent. Just visiting a URL.
And when it failed, the catch block returned this:
return NextResponse.json({
error: "Seeding failed",
details: error.message,
stack: error.stack // full stack trace to the client
})
Stack traces in API responses. In a production endpoint.
Why this happens
Test helpers get written fast. Someone needs dummy data for local development, writes a quick seed route, ships it, and moves on. The app works. Nobody notices the route is still live because in development it's harmless — you're the only one hitting it and the data doesn't matter.
The gap is that test helpers need the same production discipline as real routes:
- gate them behind
NODE_ENV === "development"or remove them entirely - never expose mutation via GET — GET should be read-only by HTTP convention
- never return
error.stackto the client
The fix was straightforward. Block the route in production, switch mutation to POST, strip the stack trace from the error response:
// after — PR #451
export async function GET() {
return NextResponse.json(
{ error: "Method not allowed" },
{ status: 405 }
)
}
export async function POST(request: Request) {
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "Not found" }, { status: 404 })
}
// seeding logic only runs in dev/test
await db.insert(notificationsTable).values(dummyNotifications)
return NextResponse.json({ success: true })
}
GET returns 405. POST is the only mutation path. Production returns 404. Error responses return a generic message — no stack, no details.
I also added Jest coverage for the blocked GET, the successful POST seed, and the production gate. Because a security fix without a test is just a comment promising it won't happen again.
The notification bell polling before auth
While fixing the seed route I was already in the notification code, so I opened src/components/NotificationBell.tsx.
Line 31:
const { data, error } = useSWR('/api/notifications', fetcher, {
refreshInterval: 10000
})
Unconditional. No auth check. No isLoaded guard.
The notification bell starts polling /api/notifications every 10 seconds from the moment the component renders — before Clerk has confirmed whether the user is signed in, during sign-in, after sign-out, after persistent API failures. It just keeps going.
Open the network tab and you'd see a stream of requests hitting the notifications endpoint from the second the page loads, regardless of auth state. Each one returning a 401 for a signed-out user. Each one triggering an automatic SWR retry. Repeated 401s, noisy server logs, wasted work — all for a header widget that can't show useful data in that state anyway.
The fix is gating the SWR key on Clerk auth state. SWR treats a null key as "don't fetch" — so you pass null when the user isn't loaded or isn't signed in, and the real URL only when auth is confirmed:
// before
const { data, error } = useSWR('/api/notifications', fetcher, {
refreshInterval: 10000
})
// after — PR #442
const { isLoaded, isSignedIn } = useAuth()
const { data, error } = useSWR(
isLoaded && isSignedIn ? '/api/notifications' : null,
fetcher,
{
refreshInterval: 10000,
onErrorRetry: (error, _key, _config, revalidate, { retryCount }) => {
if (retryCount >= 3) return // stop retrying after 3 failures
setTimeout(() => revalidate({ retryCount }), 5000)
}
}
)
Two changes: the SWR key is now conditional on isLoaded && isSignedIn, and repeated failures stop retrying after 3 attempts instead of hammering the API forever.
I also added a retry state — instead of showing an empty notification list when loading fails, the component now shows a "Try again" button. Because an empty list and a failed load are two completely different states and the user deserves to know which one they're looking at.
The bug that made failed requests look like empty data
Same theme, different components.
In src/app/bookmarks/page.tsx and src/components/RecommendedClassrooms.tsx, failed API requests were being silently swallowed:
// bookmarks/page.tsx — before
try {
const res = await fetch('/api/bookmarks')
const data = await res.json()
setBookmarks(data)
} catch (err) {
console.error(err) // logged, not stored
// falls through to empty state
}
If /api/bookmarks returned a 500, the catch block logged it and the component fell through to rendering "no bookmarks." Same for recommendations. A user staring at an empty bookmarks page had no way to know whether they had no bookmarks or the app had failed to load them.
Quick way to verify: temporarily return a 500 from either route. The UI shows "no bookmarks" like everything is fine.
The fix is checking res.ok before treating the response as valid data, and keeping an error state separate from empty data:
// after — PR #444
try {
const res = await fetch('/api/bookmarks')
if (!res.ok) {
setError(`Failed to load bookmarks (${res.status})`)
return
}
const data = await res.json()
setBookmarks(data)
} catch (err) {
setError('Something went wrong. Try again.')
}
Empty state only shows after a successful empty response. Failed responses show a clear message with a retry button. Two states, two different UIs. Simple.
The frontend-API contract mismatch
Last one. This one was pure API contract drift.
src/components/InfiniteDoubtFeed.tsx is a reusable infinite scroll component. It was written expecting each SWR page to look like this:
// what InfiniteDoubtFeed expected
{ doubts: Doubt[], pagination: { hasMore: boolean } }
But src/app/api/doubts/route.ts returned this:
// what the API actually returned
Doubt[] // plain array
So page?.doubts was always undefined. The feed flattened to an empty list. Pagination never worked. The component had been built for a response shape the API never provided.
Nobody noticed because the component probably hadn't been wired into a live page yet — but it would have silently shown empty data the moment it was used.
// before — PR #443
const doubts = data?.pages.flatMap(page => page.doubts) ?? []
const hasMore = data?.pages.at(-1)?.pagination?.hasMore ?? false
// after — handles both shapes
const doubts = data?.pages.flatMap(page =>
Array.isArray(page) ? page : (page.doubts ?? [])
) ?? []
const hasMore = data?.pages.at(-1) !== undefined
? Array.isArray(data.pages.at(-1))
? (data.pages.at(-1) as Doubt[]).length > 0
: (data.pages.at(-1) as { pagination: { hasMore: boolean } })
.pagination?.hasMore ?? false
: false
The component now handles both the current plain array response and the object shape with pagination — so it works today and keeps working if the API changes later. I also made the local fetcher throw on non-2xx responses so failed loads don't get treated as valid page data.
The pattern across all four bugs
These are different bugs but they share the same root cause: things that work in development silently failing in production — or worse, silently misbehaving without failing at all.
The test seed route worked fine locally because the developer was the only one hitting it. The notification polling worked fine in a signed-in session because that's the only state it was tested in. The empty state bug was invisible until you deliberately broke the API. The contract mismatch was invisible until you wired the component to a real page.
The fix for all of them is the same mindset: test failure cases, not just happy paths. What happens when the user is signed out? What happens when the API returns a 500? What happens when the response shape doesn't match what the component expects?
If you're building a Next.js app right now — open your network tab, sign out, and watch what requests fire. You'll probably find something.
Links
- PR #451: fix: guard notification test seed route
- PR #442: fix: gate notification polling by auth
- PR #444: fix: show errors for failed saved data loads
- PR #443: fix: normalize infinite doubt feed pages
- Repo: knoxiboy/DoubtDesk
Part of my GSSoC 2026 contribution series. Currently #50 globally, S Tier, top 1% of 43,587 contributors. Writing about the bugs that actually taught me something — one post every two weeks.
Next up: fixing concurrent cache misses causing a thundering herd in a Next.js GitHub analytics tool.
Top comments (0)