GlassKit

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:

LayerWhereWhat it catches
ConvexErrorServer (Convex actions/mutations/queries)Failures the caller needs to know about: unauthenticated, rate-limited, missing config, business-rule violations
Per-call try/catchClient (React components, server components)Unwrap ConvexError.data.userFacingMessage for branded copy
<AppErrorBoundary>Vite glasses app rootAnything the per-call catches missed: render crashes, unhandled promise rejections React re-throws
error.tsxCompanion App RouterServer / client crashes inside a route segment
not-found.tsxCompanion App Router404s and explicit notFound() calls
global-error.tsxCompanion App RouterErrors 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:

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:

app/src/lib/useAi.ts
} 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:

app/src/main.tsx
<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:

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.

On this page