Skip to main content
ASoc
Tutorial

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.

The ASoc Team10 min read

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.error line shows only the failure itself.
  • No paging. The ALERT marker 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

SymptomCauseFix
A Server Action failure never shows up anywhereThe action returned a typed error result but nothing logged it before returningAdd a console.error("domain: what failed", error) before the return, same as every other action here
Can't tell which of several download: failures happenedLog 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 messageThe catch block re-threw or returned the caught error's .message directlyReturn 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 tracesLogging the full error object for a condition that isn't actually exceptionalLog error.message only for expected/routine branches, as download: signed URL creation failed does
Nobody notices a webhook reconciliation failure for daysconsole.error alone has no paging mechanismWire 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 RouterTrying to wire it up outside the supported hookUse 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.

Keep reading

Tutorial13 min read

Gated File Downloads in Next.js: The Order the Checks Must Run In

Authenticate, verify, validate, authorize, rate-limit and record — then sign. The atomic limiter that closes the count-then-insert race, and what fails closed versus open.

Read more
Tutorial13 min read

Next.js Image Optimization Without next/image: 255 KiB to 33 KiB

A closed image set does not need a request-time optimiser. The sharp build script, the plain-img markup, and the lazy LCP image that cost us 1.67s of load delay.

Read more