-
Notifications
You must be signed in to change notification settings - Fork 7
Error handling
Summary: Errors are inevitable. This page explains how StartER handles them, and how to add proper error handling to your own modules.
What you'll learn:
- Identify which errors are handled by middleware vs. your code
- Create errors with custom HTTP status codes
- Understand how React error boundaries work in StartER
When something goes wrong during a request, StartER follows a clear chain:
Request
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Middleware chain │
│ │
│ cookieParser → csrf → json → validator → paramConverter │
│ │ │ │ │ │
│ │ │ 400 (bad body) 404 (not found) │
│ │ 401 (missing │
│ │ CSRF token) │
│ ▼ │
│ Action │
│ │ │
│ ├── 200/201/204 (success) │
│ └── throw Error ──────────────────────────────┐ │
│ │ │
└─────────────────────────────────────────────────────│────────┘
│
▼
┌────────────────┐
│ logErrors │
│ (console.error)│
└───────┬────────┘
│
▼
┌────────────────┐
│ sendErrors │
│ (JSON response)│
└────────────────┘
The key insight: most errors are already handled before your action runs. The middleware chain catches:
- 400: invalid request body (validator)
- 401: missing CSRF token (csrf middleware), invalid/missing auth token (verifyAccessToken)
- 404: resource not found (param converter)
- 403: ownership mismatch (checkAccess in routes)
Your action only needs to handle errors specific to its business logic.
StartER uses res.sendStatus() for lightweight status handling (which sends standard Express plain-text status messages) and Zod for structured 400 Bad Request validation errors:
| Status Code | Status Name | Express Method | Trigger / Raised By | Response Body Format |
|---|---|---|---|---|
400 |
Bad Request | res.status(400).json(...) |
createValidator (Zod validation failure) |
{ issues: ZodIssue[] } (JSON object) |
401 |
Unauthorized | res.sendStatus(401) |
verifyAccessToken or csrf middleware |
"Unauthorized" (plain text) |
404 |
Not Found |
res.sendStatus(404) (or 204 on DELETE)
|
createParamConverter or unknown API route |
"Not Found" (plain text) (or empty body for 204)
|
409 (Optional)
|
Conflict | res.sendStatus(409) |
Custom action (e.g. email collision handling) |
"Conflict" (plain text) |
422 (Optional)
|
Unprocessable Entity | res.status(422).json(...) |
Optional custom domain rule in your module | Custom JSON / plain text |
500 |
Internal Server Error |
sendErrors middleware |
Global error handler in server.ts
|
Dev: { message, stack }Prod: { message: "Internal Server Error" }
|
At the bottom of server.ts, two error-handling middlewares catch anything that slips through:
// 1. Log the error for debugging
const logErrors: ErrorRequestHandler = (err, req, _res, next) => {
console.error(err);
console.error("on req:", req.method, req.path);
next(err);
};
// 2. Send a structured JSON response
const sendErrors: ErrorRequestHandler = (err, _req, res, _next) => {
const status = err.status ?? err.statusCode ?? 500;
res.status(status).json({
message: err.message ?? "Internal Server Error",
// Stack trace is only exposed in development
...(isProduction ? {} : { stack: err.stack }),
});
};This means any uncaught error in your action will automatically result in a 500 Internal Server Error JSON response. In development, the stack trace is included for debugging. In production, it is hidden.
Most of the time, you don't need to. Let errors bubble up to the global handler. But there are cases where you should catch:
If a database operation fails due to a business constraint (e.g., unique violation), you may want to return a 409 Conflict instead of a generic 500:
const add: RequestHandler = (req, res) => {
try {
const insertId = groupRepository.create(req.body);
res.status(201).json({ insertId });
} catch (err) {
// SQLite throws on UNIQUE constraint violations
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
res.status(409).json({ message: "A group with this name already exists" });
return;
}
// Re-throw unexpected errors to the global handler
throw err;
}
};Important
Always re-throw errors you don't explicitly handle. Swallowing errors silently is the most common source of "impossible" bugs.
If your action performs multiple operations (e.g., creating a record and sending an email), you may need to catch the second operation's failure to avoid partial state:
const register: RequestHandler = async (req, res) => {
const userId = userRepository.create(req.body);
try {
await sendWelcomeEmail(req.body.email);
} catch {
// Email failed, but the user was created.
// Log the failure but don't break the flow.
console.error(`Failed to send welcome email to ${req.body.email}`);
}
res.status(201).json({ insertId: userId });
};For multi-table operations, use SQL transactions to ensure atomicity. See Many-to-many and transactions for the full pattern with BEGIN, COMMIT, and ROLLBACK.
Express 5 supports the err.status convention. You can create errors with specific status codes:
const edit: RequestHandler = (req, res) => {
const updated = itemRepository.update(req.item.id, req.body);
if (!updated) {
// The row existed when the param converter ran,
// but was deleted between then and now (race condition).
const error = new Error("Item was deleted during the request");
(error as any).status = 410; // Gone
throw error;
}
res.sendStatus(204);
};The global sendErrors handler will read err.status and use it as the HTTP status code.
StartER deliberately keeps error responses concise:
| Status | Meaning | What to say |
|---|---|---|
| 400 | Bad Request | Return Zod validation issues (safe: they describe expected fields, not internal state) |
| 401 | Unauthorized | Return nothing (sendStatus(401)). Never reveal why authentication failed |
| 403 | Forbidden | Return nothing (sendStatus(403)). Never reveal the ownership rule |
| 404 | Not Found | Return nothing (sendStatus(404)). Never confirm whether the resource exists but is inaccessible |
| 500 | Server Error | Return a generic message. Never expose stack traces, SQL errors, or internal paths in production |
Warning
Detailed error messages are a gift to attackers. A 401 response that says "Invalid JWT signature" tells an attacker that they have a JWT and it's almost working. A plain 401 tells them nothing.
The sections above cover Express (API) errors. On the React side, two mechanisms handle loading states and component failures.
In Layout.tsx, the <Outlet /> is wrapped in a <Suspense> boundary:
<main>
<Suspense fallback={<p>Loading…</p>}>
<Outlet />
</Suspense>
</main>When a component calls use(getOrFetch("/api/something")), React suspends the component. The <Suspense> boundary catches this suspension and displays the fallback while data is being fetched. The header and navigation remain visible during this time.
Note
This is a single boundary at the <Outlet> level. Individual components do not need their own <Suspense>. Adding more granular boundaries is possible but not required for a starter project.
In routes.tsx, all child routes are wrapped in a pathless route with an errorElement:
children: [
{
// Pathless wrapper: error boundary for all page routes.
// React Router renders <Outlet /> by default (no Component needed).
errorElement: <ErrorPage />,
children: [
{ index: true, element: <Home /> },
// ...other routes
],
},
]When a component throws (e.g., getOrFetch("/api/something") fails due to a network error), this error boundary catches it and renders ErrorPage inside the Layout. The navigation stays visible and the user can navigate back: no full page crash.
Note
The root route also has its own errorElement as a last-resort fallback. It catches errors that occur before the Layout renders (e.g., loader failures). The pathless wrapper handles errors inside the Layout.
- Let middleware do the work: validators, param converters, and auth middleware already handle 80% of error scenarios. Don't duplicate their logic in your actions.
- Catch specifically, re-throw generically: only catch errors you know how to handle. Everything else should reach the global handler.
-
Log mutations: consider adding
console.infologs for successful mutations (POST, PUT, DELETE) to aid debugging and auditing. -
Test error cases: in your contracts, always define
bad_request,unauthorized,forbidden, andnot_foundscenarios. These are the errors your users will actually encounter.
AI co-creation
Getting started
Explanations
How-To Guides
Reference
Digging deeper