Next.js Deployment Learnings: When Framework Defaults Silently Break External Integrations
Both of these were invisible in development and only surfaced in production. No exceptions thrown, no obvious errors — just silent failures at the boundary between our app and external services.
A webhook is a URL on your server that an external service calls automatically when something happens on its end. Instead of your app asking "did anything happen?" every few seconds (polling), the service pushes data to you the moment it occurs.
In Senquel: when someone sends a WhatsApp message, Meta or Twilio immediately POSTs the message payload to /api/whatsapp/webhook. Your route handler processes it and writes to the database — all within milliseconds, with no polling needed.
1. Auth middleware that corrupts webhook responses
Senquel uses Supabase SSR, which ships a middleware that calls supabase.auth.getUser() on every request to keep the session cookie fresh. This is correct for page routes — authenticated users need their tokens refreshed.
The problem: it was also running on /api/whatsapp/webhook. When Twilio POSTs an inbound message, there are no cookies in the request. The middleware ran anyway, attempted getUser(), got nothing back, and — depending on the Supabase SSR internals — either modified response headers or short-circuited the response before the route handler could return TwiML. Twilio received a malformed response and logged a delivery failure. Nothing in the Next.js logs looked wrong.
TwiML (Twilio Markup Language) is an XML-based format that tells Twilio what to do with a call or message. When Twilio POSTs to your webhook, it reads your HTTP response body as XML instructions.
Example: <Response><Message>Received!</Message></Response> tells Twilio to reply with that text. If Twilio receives a redirect, empty body, or garbled content instead, it logs a delivery failure and the client never sees a reply.
The fix was one addition to the middleware matcher config — a negative lookahead that excludes all api/ paths:
// middleware.ts — before
'/((?!_next/static|_next/image|favicon.ico|.*\.(?:svg|png|…)$).*)'
// middleware.ts — after
'/((?!_next/static|_next/image|favicon.ico|api/|.*\.(?:svg|png|…)$).*)'
// ^^^^
How the middleware matcher fix works
The principle applies beyond Supabase: any middleware that reads cookies, sets headers, or conditionally short-circuits a response should have an explicit exclusion list for stateless external-facing routes. Webhooks from Twilio, Meta, Stripe, GitHub — all of them arrive with no session context. They need a deterministic, unmodified response body. Middleware should never see them.
The check in code is cheap:
// Confirm your webhook is excluded before anything else
// GET https://your-domain.com/api/whatsapp/webhook
// Should return your handler's GET response, not a redirect or 401
2. Environment variables that only exist locally
We had a SKIP_TWILIO_VALIDATION=true guard in the webhook to bypass Twilio signature validation during local development:
if (process.env.SKIP_TWILIO_VALIDATION === 'true') {
console.warn('Skipping Twilio signature validation (dev mode)');
return true;
}
The intent was to set this only in .env.local so it would never reach production. But Vercel does not inherit .env.local — it only uses environment variables explicitly set in the Vercel dashboard or via vercel env. If you rely on .env.local as a "local only" gate, that gate only works if you never accidentally set the variable in Vercel.
The safer pattern: remove the env-var guard entirely. If you need to bypass validation for a debugging session, make the bypass explicit in source code with a prominent comment, and treat it as temporary diagnostic code to be reverted before the next commit:
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function validateTwilioSignature(_req: NextRequest, _rawBody: string): boolean {
// TEMPORARY: bypassed to diagnose URL mismatch. Revert after confirming endpoint.
console.warn('[webhook] Signature validation bypassed');
return true;
}
An inline comment in source is more visible during code review than an env var in a dashboard. It also forces an explicit decision — a comment with "TEMPORARY" in it doesn't accidentally stay in production unnoticed the way an environment variable can.
.env.local is a file you create at the project root for local development only. Next.js loads it automatically when running npm run dev. It is gitignored by default — it never gets committed, and Vercel never sees it.
Vercel has its own separate environment variable store: Settings → Environment Variables in the dashboard, or via vercel env add. Those are the only variables available to your deployed app. Variables you set only in .env.local are invisible to Vercel — they do not travel with a deployment.