Next.js Error Monitoring: 41 console.error Calls, Zero APM
No Sentry, no instrumentation.ts — 41 structured console.error calls, an ALERT-marker convention, and the error-hygiene test that keeps server detail out of the browser.
This codebase has no Sentry, no Highlight, no instrumentation.ts, and no APM dependency of any kind — grep package.json's 13 production dependencies and none of them is a monitoring vendor. What it has instead is 41 structured console.error calls across the server code, a naming convention consistent enough to grep by domain, and a two-tier split between routine failures and ones that page a human. Here's what that surface actually catches, and where the real gaps are.
Error monitoring is two different jobs
"Next.js error monitoring" usually means one of two things, and they're caught by different mechanisms entirely.
The first is a client-side render throwing — a bad prop, a null dereference, a Server Component's data fetch failing mid-render. That's the job error.tsx and global-error.tsx do, covered in depth in the error boundary audit: the boundary renders a fallback and exposes error.digest, a hash Next.js logs server-side in place of the real message it strips from the production response.
The second is a server-side operation failing without ever throwing past a boundary — a Server Action's Supabase call rejecting, a webhook's signature check failing, a Route Handler's rate-limit RPC erroring. Nothing in the App Router catches these automatically; the code that calls them has to log them itself. This post is about that second job, because it's the one with no framework convention to lean on — and the one every real production failure in this app actually goes through.
The convention: domain: what failed
Every server-side catch block in this codebase writes to console.error with the same shape — a colon-separated prefix naming where the failure happened, then the failure itself:
// src/lib/actions/auth.ts
console.error("auth: signInWithPassword error", error);
// src/app/api/download/route.ts
console.error("download: slot lookup failed", error);
// src/lib/actions/redemption.ts
console.error("redemption: atomic update failed", updateError);
// src/components/organisms/Header.tsx (the one CLIENT-side instance)
console.error("Header: session check failed", error);
Grep the codebase and there are 41 of these outside tests and blog content, spread across 19 files — every Server Action, every Route Handler, and the one Client Component (Header) whose auth-state check can fail on a stale cookie. That prefix is the entire indexing scheme: on Vercel, these lines land in Runtime Logs, and grep "download:" or grep "auth:" against a log export is how an incident gets scoped without a dashboard to query. It costs nothing to add and nothing to maintain — it's a string literal, not a library.
Routine failures vs. ones that page a human
Not every caught error is equally urgent, and this codebase's most interesting monitoring decision is the one place it says so explicitly. The LemonSqueezy webhook handler distinguishes "log it and move on" from "this needs a person" with a literal ALERT marker:
// src/app/api/webhooks/lemonsqueezy/route.ts
onUnattachedOrder: ({ lsOrderId, email, tier }) => {
// Observability alert (spec §6/A10) — cheapest option at this stage is
// a structured console.error (matches the rest of the codebase's
// server-log pattern); pick up a log drain/Sentry alert on top later.
console.error("lemonsqueezy webhook ALERT: unattached order", {
lsOrderId,
email,
tier,
});
},
onOrphanRefund: ({ lsOrderId }) => {
// order_refunded arrived for an order we have no row for (e.g. the
// refund webhook raced ahead of order_created, or the create delivery
// was lost) — needs manual reconciliation, same alert channel as above.
console.error("lemonsqueezy webhook ALERT: refund for unknown order", {
lsOrderId,
});
},
Both cases mean a buyer paid and the storefront doesn't yet know what to give them — the kind of failure that needs a human to reconcile, not just a log line to reference later. The comment is honest about what this is: the cheapest possible alerting channel, a console.error with a grep-able marker, explicitly flagged as a placeholder for a real log drain or Sentry alert rule "later." That's not a gap this post is pretending isn't there — it's the actual, current state of this app's alerting, and it's worth naming because most monitoring write-ups skip straight to the vendor step without showing what the zero-dependency version looks like first.
The other invariant: full detail server-side, nothing leaked to the user
Logging the failure is half the job; the other half is making sure what gets logged never reaches the browser. This codebase has a named rule for it — R-6/A13, "error hygiene" — and a real test enforcing it, not just a comment promising it:
// src/lib/__tests__/error-hygiene.test.ts
const INTERNAL_MARKERS = [
"PG::UniqueViolation",
"secret_table",
"ECONNREFUSED",
"10.0.0.42",
"internal-db.svc.local",
"500",
"at Object.<anonymous>", // stack-frame shape
];
// ...injects each marker into a mocked upstream failure, then:
expect(message).toBe(GENERIC);
for (const marker of INTERNAL_MARKERS) {
expect(message).not.toContain(marker);
}
The test drives subscribeToWaitlist and sendContactMessage through a fake 500 response and a fake network rejection, both carrying fabricated internals (a Postgres constraint name, a private IP, a stack frame), and asserts the user-facing message is the fixed generic string every time — while console.error still receives the full object underneath (the test spies on it and confirms it was called, then discards the detail). Monitoring that leaks a database hostname into a browser's network tab isn't monitoring done wrong so much as a second incident stacked on the first; catching it with a test rather than a review checklist is what keeps it enforced as the codebase grows past what one person can visually audit.
Where these logs actually land
There's no instrumentation.ts file in this project — the one App Router convention meant for wiring up OpenTelemetry or a monitoring SDK's server-side hook is unused here. Every console.error call goes straight to stdout, which on Vercel becomes a Runtime Logs entry: searchable, filterable by route and time range, and exportable to a log drain if one gets configured later. That's a real, if basic, monitoring surface — it just isn't a dashboard, and it doesn't page anyone by itself.
The ALERT-marker comment above names the actual next step honestly: a log drain with an alert rule on the string ALERT, or a Sentry/Highlight integration that turns these same call sites into typed events with stack traces, breadcrumbs, and release tagging. Nothing about the current convention blocks that path — every one of the 41 call sites is already a single, greppable choke point, which is exactly what makes bolting a real SDK onto it later a mechanical change rather than a rewrite.
When a console.error convention stops being enough
This isn't an argument that structured logging is sufficient forever. It genuinely is not, past a certain size or failure rate:
- No trend data.
grep-ing Runtime Logs answers "did this happen," not "is this happening more than last week" — that needs a dashboard tracking error rates over time. - No session context. An APM's session replay or breadcrumb trail shows what a user did before the failure. A
console.errorline shows only the failure itself. - No paging. The
ALERTmarker is discoverable by someone actively watching logs. It doesn't wake anyone up at 2 a.m. on its own.
The honest scoping rule this codebase follows is: structured logs are enough while the failure volume is low enough that a human can plausibly grep them, and the trigger for adding a real APM is either sustained volume or an incident that a log line couldn't have prevented because nobody was watching when it happened.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| A Server Action failure never shows up anywhere | The action returned a typed error result but nothing logged it before returning | Add a console.error("domain: what failed", error) before the return, same as every other action here |
Can't tell which of several download: failures happened | Log line lacks distinguishing context (user id, product slug) | Pass a context object, not just a string — see onUnattachedOrder's { lsOrderId, email, tier } shape above |
| User sees a raw database error message | The catch block re-threw or returned the caught error's .message directly | Return the fixed generic string; log the real error server-side only (R-6/A13) |
| Routine, expected failures (a not-yet-uploaded file) generate noisy stack traces | Logging the full error object for a condition that isn't actually exceptional | Log error.message only for expected/routine branches, as download: signed URL creation failed does |
| Nobody notices a webhook reconciliation failure for days | console.error alone has no paging mechanism | Wire a log drain or Sentry alert rule on the ALERT marker string — the codebase's own TODO for this |
| Error monitoring library won't compile against the App Router | Trying to wire it up outside the supported hook | Use instrumentation.ts's register() export, the framework's documented integration point — absent in this project on purpose, for now |
FAQ
Do I need Sentry for a Next.js app?
Not from day one. This storefront runs in production on structured console.error calls and Vercel's Runtime Logs alone — genuinely searchable, genuinely useful for a low-volume app. The tradeoff is no trend dashboards, no session replay, and no automatic paging; add an APM when any of those three becomes something you actually need, not preemptively.
What's the difference between error monitoring and an error boundary?
An error boundary (error.tsx) catches a client-side render throw and shows a fallback UI — it's a user-experience mechanism. Error monitoring is about server-side operations that fail without ever reaching a boundary (a Server Action, a webhook, a Route Handler) and need their own explicit logging, which is what this post covers.
Where do console.error calls actually go in production?
On Vercel, straight to Runtime Logs — searchable and filterable, but not a dashboard and not something that pages anyone by default. A log drain (or an APM's ingestion endpoint) is what turns that stream into alerts.
Is a naming convention like "domain: what failed" actually worth it?
Yes, disproportionately to its cost. It's the entire indexing scheme for 41 call sites across 19 files, costs nothing to add, and is what makes grep "webhook:" against an exported log a real incident-scoping tool instead of a wall of undifferentiated text.
Templates in this post
ASoc Vertex Admin is a sales-analytics dashboard whose data fetches are exactly the kind of server-side operation this post is about — failures that need explicit logging, not a boundary. ASoc Apex Admin ships a full admin UI kit where every data-mutating action benefits from the same domain-prefixed convention. ASoc Clover Admin is a CRM dashboard, the category where a silent, unmonitored write failure costs the most.
Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the client-side half of this surface — what error.tsx catches and what error.digest is for — see the error boundary audit.
