Error handling
ConvexError on the server, error boundaries on both apps, branded App Router error pages. Where to throw, where to catch.
GlassKit ships a layered error story:
| Layer | Where | What it catches |
|---|---|---|
ConvexError | Server (Convex actions/mutations/queries) | Failures the caller needs to know about: unauthenticated, rate-limited, missing config, business-rule violations |
| Per-call try/catch | Client (React components, server components) | Unwrap ConvexError.data.userFacingMessage for branded copy |
<AppErrorBoundary> | Vite glasses app root | Anything the per-call catches missed: render crashes, unhandled promise rejections React re-throws |
error.tsx | Companion App Router | Server / client crashes inside a route segment |
not-found.tsx | Companion App Router | 404s and explicit notFound() calls |
global-error.tsx | Companion App Router | Errors that escape the root layout itself |
The contract
Each layer has a single, clear responsibility. Throw ConvexError
for things callers should react to; let everything else bubble to
a boundary.
ConvexError({ code, userFacingMessage })
The structured error type for any backend failure that needs to
surface specific copy to the client. From
packages/backend/convex/pairing.ts:
import { ConvexError } from "convex/values";
import { authMutation } from "./lib/auth";
export const claimPairingSession = authMutation({
args: { sessionId: v.string() },
handler: async (ctx, { sessionId }) => {
// The wrapper already threw UNAUTHENTICATED if no Clerk identity,
// and ctx.identity is the validated identity for the signed-in caller.
await rateLimiter.limit(ctx, "pairingClaim", {
key: ctx.identity.subject,
throws: true,
});
// A claim against an expired or unknown session surfaces copy:
//
// throw new ConvexError({
// code: "PAIRING_SESSION_NOT_FOUND",
// userFacingMessage:
// "That Display isn't pairing anymore — tap Start Pairing on the glasses again.",
// });
// ...stamp the session with this account, flip status to "claimed"...
},
});The authMutation wrapper (and its siblings authQuery / authAction)
lives in packages/backend/convex/lib/auth.ts and follows the
Convex Stack pattern:
it checks ctx.auth.getUserIdentity(), throws
ConvexError({ code: "UNAUTHENTICATED" }) when missing, and
injects the validated identity onto ctx so handlers don't have
to re-fetch + non-null-assert.
The shape ({ code, userFacingMessage }) is a project
convention, not a Convex requirement. Convex serializes whatever
shape you put into ConvexError's constructor and surfaces it to
the client as err.data.
The convention
code: a short uppercase identifier (UNAUTHENTICATED,AI_CONFIG_MISSING,SUBSCRIPTION_NOT_FOUND). For Sentry-style grouping, programmatic branches in the catch site, and search.userFacingMessage: the exact string the client should render to the user. Write it for the user, not for the developer. No "Error:" prefix, no implementation jargon.
When to use ConvexError vs plain Error
Use ConvexError when the caller needs to show specific copy
to the user. Use plain Error (or just let it throw) for
internal failures that should land in logs but render as a
generic "Something went wrong" boundary fallback.
In practice the boilerplate uses ConvexError for all
user-reachable errors: every auth.getUserIdentity() guard,
every rateLimiter.limit() (which throws
ConvexError({ kind: "RateLimited", retryAfter }) automatically),
every config-missing check.
Unwrapping ConvexError on the client
When a useAction / useMutation / useQuery rejection bubbles
into your catch, the structured data is on err.data:
} catch (err) {
const userFacing = (err as { data?: { userFacingMessage?: string } })
?.data?.userFacingMessage;
setError(
userFacing ??
(err instanceof Error ? err.message : "AI request failed"),
);
}The duck-typed access
((err as { data?: ... })?.data?.userFacingMessage) is
intentional. The ConvexError class instance doesn't always
survive serialization across the Convex wire (different runtimes,
test environments, etc.), so the structured-data check is more
reliable than err instanceof ConvexError.
Pattern in summary
try {
await action({ /* args */ });
} catch (err) {
const message =
(err as { data?: { userFacingMessage?: string } })?.data?.userFacingMessage ??
(err instanceof Error ? err.message : "Something went wrong");
// Use `message` — render in your UI, surface as a toast, etc.
}Server-side (Next.js route handlers, server actions), same
pattern. fetchAction from convex/nextjs rejects with the same
shape.
<AppErrorBoundary>: the Vite glasses app
The Vite app wraps its tree in a custom error boundary that catches React render errors + uncaught client-side rejections:
<StrictMode>
<AppErrorBoundary>
<ClerkProvider publishableKey={env.VITE_CLERK_PUBLISHABLE_KEY}>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
<PairingGate>
<App />
</PairingGate>
</ConvexProviderWithClerk>
</ClerkProvider>
</AppErrorBoundary>
</StrictMode>Built on
react-error-boundary.
The fallback (app/src/AppErrorBoundary.tsx) is sized for the
600×600 surface with a single primary action: reset the boundary
and retry.
Why it matters more on the glasses app
The 600×600 surface has no browser UI: no devtools, no refresh
button. An uncaught render error without a boundary leaves a
frozen black screen and the user has no way back. The boundary's
fallback shows a "Try again" focusable that resets state via the
boundary's resetErrorBoundary() callback.
Customizing the fallback
The fallback component is in app/src/AppErrorBoundary.tsx. Edit
freely: change copy, add a sign-out button, add an <Image>
mark, whatever fits your brand. The only requirement is a
focusable element (className="focusable") so the D-pad can reach
it.
When NOT to put a boundary
Don't wrap individual buttons or small components. The boundary is an escape hatch for render-level surprises, not for known operational errors. Use the per-call try/catch pattern (above) for expected failures. The user wants "AI is turned off, set your Anthropic key" copy, not "Something went wrong."
Companion: error.tsx, not-found.tsx, global-error.tsx
Next.js App Router auto-loads these by filename. The boilerplate
ships all three at companion/app/:
error.tsx: route-level boundary
Renders when a server or client component below the root layout
throws. Receives { error, reset }. Stays inside <SiteHeader>'s
nav so the user keeps orientation.
Branded copy: "That didn't work." + a "Try again" button (calls
reset()) + a "Go home" link. In NODE_ENV !== "production",
also shows error.message + error.digest in a dev panel.
Required to be a Client Component ("use client" directive at the
top) because it needs the reset() callback.
not-found.tsx: 404
Renders for unmatched routes and any explicit notFound() call
from a server component. Server Component, no client interaction
needed.
Branded copy: "Nothing here." + three pointer links (Home, Dashboard, Pricing), best-effort to get the user somewhere useful.
global-error.tsx: root-layout boundary
Renders when an error escapes the root layout itself. Has to ship
its own <html> and <body> because it REPLACES the root layout
(which is where error.tsx would normally mount).
Styles are inlined (not from globals.css): at the point this
file renders, the stylesheet may not be guaranteed to have loaded.
Branded copy: "Something went really wrong." + a "Reload" button
that calls reset().
Required "use client" for the same reset() reason as
error.tsx.
Where the layers cooperate
A typical failure path:
Convex action throws
ConvexError({ code: "AI_CONFIG_MISSING", userFacingMessage: "AI responses are turned off..." })
The hook's catch (useAi.ts) unwraps err.data.userFacingMessage
→ sets error state in the demo component.
The demo renders <AiResponse> which shows the error in a
branded block.
<AppErrorBoundary> never fires; the error was handled at
step 2.
A path where the boundary DOES fire:
A component renders <UndefinedComponent /> (programming bug).
React throws during render.
No per-call try/catch; it's a render error, not an async one.
<AppErrorBoundary> catches → renders fallback with "Try again".
User taps the focusable → resetErrorBoundary() → React
re-renders the subtree.
Both paths converge to a branded UI; neither leaves the user staring at a frozen screen or a stack trace.
Adding your own error code
The convention is just { code, userFacingMessage }. Pick a
short, screaming-snake-case code; write the user-facing message
in your product's voice. There's no central registry; codes are
typed loosely (string), and you can document them per-action
inline.
A real example from packages/backend/convex/ai.ts:
throw new ConvexError({
code: "AI_CONFIG_MISSING",
userFacingMessage:
"AI responses are turned off — the workspace owner needs to add an Anthropic API key.",
});The code is greppable. The message is read by the user. Both ship.