Skip to content

Error handling

rocambille edited this page Jul 27, 2026 · 4 revisions

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

How errors flow 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.

HTTP status codes reference table

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" }

The global error handlers

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.

When to catch errors in your actions

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:

Case 1: you want a specific status code

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.

Case 2: you need to clean up a side effect

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 });
};

Case 3: transactional operations

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.

Creating errors with status codes

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.

Error responses and security

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.

React-side error handling

The sections above cover Express (API) errors. On the React side, two mechanisms handle loading states and component failures.

Suspense: loading states

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.

Error boundary: pathless wrapper route

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.

Best practices and use cases

  • 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.info logs for successful mutations (POST, PUT, DELETE) to aid debugging and auditing.
  • Test error cases: in your contracts, always define bad_request, unauthorized, forbidden, and not_found scenarios. These are the errors your users will actually encounter.

See also

Clone this wiki locally