Health check endpoint in Next.js
Create app/api/health/route.ts with a GET handler that runs one cheap query against your database, returns 200 when it succeeds and 503 when it throws, and add export const dynamic = "force-dynamic" so the response is never served from a cache.
A health endpoint exists so something outside your app can ask one question and get an honest answer: can this instance serve a request right now. The Next.js server is a poor judge of that on its own. It stays up and keeps rendering long after the database has stopped accepting connections, so a route that returns ok: true unconditionally will report green for the entire length of an outage while your users stare at 500s.
That is why the handler has to touch the thing it cannot work without. One query that reads nothing, select 1, and a 503 when it throws. Logdash flips a monitor to down on any status code outside 200 to 399 and fires the alert on that transition, so the 503 is not decoration - it is the entire mechanism by which the route becomes a page you get woken up by.
The route handler
import { db } from '@/lib/db';
// Never prerender or cache a health check.
export const dynamic = 'force-dynamic';
export async function GET() {
try {
await db.execute('select 1');
return Response.json({ ok: true });
} catch {
// 503: the process is alive, the app is not servable.
return Response.json({ ok: false, error: 'db' }, { status: 503 });
}
}Two details in that handler are deliberate. The query is the cheapest one the driver can send, so a check costs a connection and a round trip and nothing more. And the failure body has two fields, because the only consumer that matters never opens it.
What to check and what to leave out
- The database, with a query that reads no rows. A count over a real table is a load test you would then be running every 5 minutes forever.
- Anything a request genuinely cannot complete without: Redis if sessions live there, the queue if the handler enqueues work.
- Nothing you do not own. A health route that calls Stripe hands Stripe the ability to wake you at 3am about an outage you cannot fix.
- Under a second, end to end. The Logdash pinger gives up after 10 seconds and records the check as down, and a route with a slow query is slowest exactly when the system is already in trouble.
- No commit hash, no environment dump, no dependency versions. The URL is public and the monitor only ever reads the status code.
Point a monitor at it
- Add the URL Add a monitor in Logdash and give it https://yourapp.com/api/health. The first check runs straight away, so a wrong path shows up in the next few seconds rather than during an incident.
- Choose the interval Every 5 minutes on the free plan, every minute on Builder, every 15 seconds on Pro. Each check records the status code and the response time, so the latency chart builds itself with no extra work.
- Break it before production does Stop the database and leave the app running. The route starts returning 503, the monitor flips to down on the next check, and a Telegram alert arrives naming the endpoint and the code it got back.
The Next.js trap: a cached health check
Since Next.js 15, GET route handlers are dynamic by default, and force-dynamic is belt and braces. On 13 and 14 it was the opposite: a GET handler with no request-time API in it got prerendered at build, so the health route was frozen to whatever it returned on the build machine and answered 200 forever, including through outages that lasted days. Keep the export. It is one line and it survives an upgrade in either direction. Watch the CDN as well, since a health URL that Vercel or Cloudflare can answer from cache is a URL your monitor has stopped measuring. On the Pages Router none of this applies, because API routes always run per request.
import type { NextApiRequest, NextApiResponse } from 'next';
import { db } from '@/lib/db';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
try {
await db.execute('select 1');
res.status(200).json({ ok: true });
} catch {
res.status(503).json({ ok: false, error: 'db' });
}
}