DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our 53 Server Actions never throw at the UI, and one call to unstable_rethrow is why

Nakodo has 53 exported Server Actions across eight actions.ts files. Every one of them returns the same shape, nothing in the UI uses a try/catch, and the error boundary is only ever reached by a bug.

That sounds like it needs a framework. It is 60 lines.

The type is the policy

// What Server Actions return, so every caller decides success the same way:
// `r.ok`. Expected failures come back as values; only bugs reach the error
// boundary.
export type ActionOk<T extends object = object> = {
  ok: true;
  message?: string;
  upgrade?: boolean;
  error?: undefined;
  values?: undefined;
} & T;

export type ActionFailure = {
  ok: false;
  error: string;
  upgrade?: boolean;
  values?: Record<string, string>;
  message?: undefined;
};

export type ActionResult<T extends object = object> = ActionOk<T> | ActionFailure;
Enter fullscreen mode Exit fullscreen mode

The detail that makes this pleasant to use is the pair of ?: undefined members on each side. ActionOk declares error?: undefined, ActionFailure declares message?: undefined. Neither is ever set. They exist so that a component holding an ActionResult can read r.error or r.message without first narrowing on ok, which is exactly what a toast wants to do:

type Result = { error?: string; message?: string; upgrade?: boolean };

export function notify(r: Result) {
  if (r.error) toast.error(r.error, { action });
  else if (r.message) toast.success(r.message, { action });
}
Enter fullscreen mode Exit fullscreen mode

Without those members, a union of two object types gives you a compile error on every property access that is not the discriminant, and you end up either narrowing in places that do not care or widening the type until it means nothing.

ActionOk<T> is intersected with T, so an action that has something to hand back does it inline rather than in a data envelope:

export async function createCampaignAction(_: unknown, form: FormData): Promise<ActionResult<{ next: string }>> {
Enter fullscreen mode Exit fullscreen mode

Success is { ok: true, next: "/campaigns/new/abc" }, and the form reads r.next. No r.data!.next, no second nullable layer that only exists to satisfy the type.

unstable_rethrow is the load bearing line

Here is the whole error translation:

const GENERIC = "Something went wrong. Try again in a moment.";

export function failure(e: unknown, { context, fallback = GENERIC }: Options = {}): ActionFailure {
  unstable_rethrow(e);
  if (e instanceof LimitError) return { ok: false, error: e.message, upgrade: true };
  if (e instanceof UserError) return { ok: false, error: e.message };
  if (context) console.error(context, e);
  else console.error(e);
  return { ok: false, error: fallback };
}
Enter fullscreen mode Exit fullscreen mode

unstable_rethrow is the first statement, and skipping it is the bug this helper exists to prevent. Next implements redirect() and notFound() by throwing. A catch that converts every throwable into a friendly message will catch those too, and the symptom is not an error: it is a redirect that silently becomes a toast saying "Something went wrong" while the user stays on the page. The behaviour is correct everywhere except the one place it matters, which makes it very hard to find by testing.

So failure() hands Next's own control flow signals straight back to Next, then deals with real errors.

After that there are exactly three outcomes, and the class hierarchy encodes the order they are checked in:

// An error whose message is written for the user and safe to show as is.
export class UserError extends Error {}

// Thrown when an action would go over the plan. The message is shown to the
// user next to an upgrade link. Check for it before UserError, which it also is.
export class LimitError extends UserError {
  constructor(readonly limit: string, message: string) { super(message); }
}
Enter fullscreen mode Exit fullscreen mode

LimitError extends UserError is deliberate, and so is the comment warning you about it. A plan limit is a user facing message, so inheriting is right; it just means the narrower check has to come first, and instanceof gives no help if you get the order wrong. One stray reorder and every limit message loses its upgrade link.

Everything that is neither gets logged with its context and replaced with one sentence. Nothing a database driver says ever reaches a page.

A plan limit is a value with a flag

The upgrade boolean is the smallest piece of this and the one I would keep if I had to throw the rest away. It travels from the error class through the result into the toast, and it is the only reason a limit message is actionable:

const action = r.upgrade ? (
  <Link href="/settings/plan" className="...">See plans</Link>
) : undefined;
Enter fullscreen mode Exit fullscreen mode

A limit is not a failure of the user's input, and it is not an outage. It is a fact about their account, and the only useful response to it is a link. Our plans differ on roughly a dozen numbers, from campaigns running at once to YouTube searches a day, so hitting one is routine rather than exceptional, and the UI treats it that way: red toast, plain sentence, button.

Note the flag also exists on ActionOk. An action can succeed and still have been clipped, which happens when a batch is partly over an allowance, and that case wants a success toast with the same link.

Rate limits are returned, not raised

Short term limits live outside the plan, in Redis, and they get the same treatment: a function that returns the failure rather than throwing it.

export async function tooFast(rule: RateLimitRule, userId: string): Promise<ActionFailure | null> {
  const message = await rateLimitMessage(rule, userKey(userId));
  return message ? fail(message) : null;
}
Enter fullscreen mode Exit fullscreen mode

Which makes the top of an action read as a list of guards with no nesting:

const user = await requireUser();
const slow = await tooFast("research", user.id);
if (slow) return slow;
Enter fullscreen mode Exit fullscreen mode

ActionFailure | null as a return type is worth more than it looks. It means a guard composes with if (x) return x, and the type system knows the early return is a complete result rather than a partial one.

A failed submit must not empty the form

The last member of the failure type is values, and it exists because of one specific annoyance: a user types a domain and some notes, the submit fails validation, and a server rendered form comes back empty.

export const formText = <K extends string>(form: FormData, ...names: K[]): Record<K, string> =>
  Object.fromEntries(names.map((n) => [n, String(form.get(n) ?? "").trim()])) as Record<K, string>;
Enter fullscreen mode Exit fullscreen mode

The action reads its text fields once, uses them, and attaches them to any failure:

const values = formText(form, "domain", "notes");
try {
  normalizeDomain(values.domain);
} catch {
  return fail("Enter a website domain, like example.com", { values });
}
// ...
} catch (e) {
  return { ...failure(e), values };
}
Enter fullscreen mode Exit fullscreen mode

The form does the other half with useActionState, and the whole echo is one attribute per field:

const [state, action, pending] = useActionState<ActionResult | null, FormData>(async (prev, form) => {
  const r = await createCampaignAction(prev, form);
  if (r.ok) router.push(r.next);
  return r;
}, null);

// ...
<input name="domain" defaultValue={state?.values?.domain} />
Enter fullscreen mode Exit fullscreen mode

Generic over the key names, so values.domian is a type error rather than an empty box.

Notice that the navigation happens on the client, from r.next, rather than with redirect() in the action. Both work. Returning the destination means the one function that submits this form is also the one place that decides what happens on success, and the action stays a pure request to change something.

The client half is 18 lines

For everything that is not a form, there is one hook:

export function useAction() {
  const [pending, start] = useTransition();
  const run = <T extends object>(action: () => Promise<ActionResult<T>>, onOk?: (r: ActionOk<T>) => void) =>
    start(async () => {
      const r = await action();
      notify(r);
      if (r.ok) onOk?.(r);
    });
  return [pending, run] as const;
}
Enter fullscreen mode Exit fullscreen mode

23 files use it. The call site is a button handler that passes a thunk and, when it has follow up work, a callback that only runs on success:

const [pending, runAction] = useAction();
// Every action on this screen clears the selection when it succeeds.
const run = (action: Parameters<typeof runAction>[0]) => runAction(action, () => setSelected(new Set()));
Enter fullscreen mode Exit fullscreen mode

Every toast in the product is emitted from that one notify(r). There is no toast.success anywhere else in the app, which means the wording, the styling and the upgrade link are decided in one place, and a new action gets all three by doing nothing.

What it does not do

There is no validation library wired into the result type, no automatic field level errors, and no retry. zod is used inside actions where input is structured, and its failure is converted by hand into fail(...) with a written message, because a ZodError flattened into a sentence reads like a compiler. For 53 actions in a product where the complicated part is what happens after the click rather than the click itself, a result type, three error classes and a hook have been enough, and the thing I value most about it is boring: when something goes wrong in production, the log has the context and the user has a sentence, and those are two different strings.

Top comments (0)