In this post I talk about a small pattern from my side project that turned out to be the useful piece of architecture in it: asking the server "could I do this?" before doing it.
Some context.
I'm building daihon.app, a trainer for spoken Japanese: you go through a dialogue line by line, say your lines out loud, and the browser checks your pronunciation.
Shadowing apps already exist, so the honest reason for building another one was different. I wanted real full stack experience on a stack I'd pick for a serious product: Next.js 16 with the App Router, strict TypeScript, PostgreSQL with Drizzle, my own JWT auth with httpOnly cookies, Zod on every boundary, and a lot of tests. A product with roles, limits and an admin panel is a good excuse to get all of that right.
And roles plus limits is exactly where UI code usually goes bad.
The problem.
There are four kinds of visitors: an anonymous visitor, a guest (no sign up, three trial dialogues), a registered user with a monthly quota, and an admin. Plus states: a banned user, an unverified email, a quota that resets on the 1st.
The default way to handle that is to sprinkle checks over components:
{user.role === 'admin' && <AdminLink />}
<Button disabled={user.isGuest && user.trialsUsed >= 3}>Start</Button>
Every one of these is a copy of a rule that also lives on the server. Copies drift. One day the server lets a guest start a dialogue and the button says no, or worse, the button says yes and the request fails with a 403 nobody explains.
So I set one hard rule for the project: no component decides by itself whether an action is available. It asks the server.
Dry-run.
Every protected route in the app is declared with a chain of guards: is the actor authenticated, not banned, within quota, and so on. The idea of dry-run is simple: run the same chain, but stop before the handler.
There are two ways in:
- an
X-Dry-Runheader on any route, which returns the verdict instead of executing; - a batch endpoint,
POST /api/dry-run/batch, that answers up to 20 checks in one request.
Both call the same function. That's important, because if the batch endpoint had its own logic, "direct check says yes, batch says no" would become possible.
The core is short:
export async function evaluateDryRun(entry, context, mode) {
let code, denial, guardMeta;
try {
for (const guard of entry.guards) {
const result = await guard(context);
if (!result.allowed) {
const derived = deniedStateFor(result.code);
code = result.code;
denial = entry.stateOf?.(derived) ?? derived;
guardMeta = result.meta;
break;
}
}
} catch (error) {
// the dry-run itself failed: disabled, never enabled
return { result: shape({ allowed: false, ...DRY_RUN_UNAVAILABLE }, mode, entry), failed: true };
}
if (denial === undefined) {
return { result: shape({ allowed: true, state: 'enabled' }, mode, entry), failed: false };
}
return { result: shape({ allowed: false, code, ...denial }, mode, entry, guardMeta), failed: false };
}
The guards in entry.guards are not "similar to" the ones on the real route. They are the same array. The route declares an action key from a registry instead of its own access rules, and takes its chain from there. So "dry-run said yes, the real call said no" can't be expressed in code at all.
What the UI gets back.
Not just a boolean. The response is a state plus a reason:
POST /api/attempts
X-Dry-Run: state
→ 200 {
allowed: false,
state: "disabled", // enabled | disabled | hidden
code: "GUEST_LIMIT_REACHED",
messageKey: "dryRun.guestLimit",
meta: { length: "short", limit: 3, used: 3, unlockedBy: "registration" }
}
The status is 200 even when the answer is no. The check succeeded, the action is just not available. Otherwise every hidden element on a page would show up as a 403 in the console and in monitoring.
hidden vs disabled follows one rule: no permission by role means hidden, no possibility by state means disabled. A guest never sees the admin link (showing it would leak the structure of the product). But a guest who used up three trial dialogues sees the Start button, disabled, with an explanation: "0 of 3 left, sign up to continue".
The meta part is what makes the explanation possible. The registry knows the action, but only the guard knows the actor: how much is left and when it resets. So the guard returns those numbers with its denial, and the client builds the message from them.
The mapping from error code to state lives in one table on the project, shared by server and client. A second copy on the client would drift silently.
The component.
On the client a component asks by action key, not by URL:
<Gated action="attempt.start.short" skeleton={<ButtonSkeleton />}>
{({ state, messageKey, meta }) => (
<Button disabled={state !== 'enabled'} hint={messageKey && t(messageKey, meta)}>
{t('dialogue.start')}
</Button>
)}
</Gated>
-
hiddenrenders nothing; -
disabledrenders the element inactive, with the reason next to it. For the Start button that's a caption right under it: a tooltip doesn't exist on touch screens or for keyboard users, and this is the one reason people really need to read; -
enabledis a normal render.
A provider collects all keys requested on the page and sends them in one batch. Two components with the same key share one check. A key that appears later (conditional render) gets added to the next batch with a 50 ms debounce, so late subscribers don't each fire their own request.
There is no polling. Verdicts get invalidated on events: login, logout, starting an attempt, role change. Without that, the Start button would happily stay enabled after the guest used their last trial.
Skeletons that don't blink.
While a verdict is pending, Gated shows a skeleton of the same shape and size as the final element, so the page doesn't jump.
But a skeleton shown right away has its own problem: on a fast connection it turns into a grey flash for a few dozen milliseconds, which looks broken. So there are two thresholds: if the answer comes faster than 150 ms, no skeleton at all. If the skeleton did appear, it stays for at least 300 ms. One small hook does that for the whole project, not only for dry-run.
Fail closed.
What if the database is down and a guard throws? The catch block above answers that: the dry-run result is disabled with an "unavailable" message. Never enabled.
The project has a general rule for degradation: fail open for convenience, fail closed for access. A broken check is allowed to make a button inactive. It is never allowed to make it active.
One more case from the same family: an X-Dry-Run header with an unknown value returns a validation error. Ignoring an unknown header would mean executing a real mutation where the client asked for a check.
On the client a network error is not the same as a denial either. The hook returns a tagged union, pending | ready | error, and on error the element is disabled with a connection message instead of silently disappearing.
Whole sections.
For the admin panel there is a boundary around the whole section:
<DryRunBoundary
action="admin.summary.view"
skeleton={<PageSkeleton />}
denied={({ messageKey }) => <AccessDenied messageKey={messageKey} />}
>
{children}
</DryRunBoundary>
hidden turns into a 404 (the section doesn't confirm it exists), disabled into an "access denied" screen.
And here is the part I want to underline: dry-run is UX, not security. The admin layout has its own server check, and every admin endpoint has its guards. Dry-run only makes sure the UI tells the truth about what those guards will say.
Cheap on purpose.
Dry-run checks are not written to the activity log. Every user action in the app is logged (it's a product requirement), but a log row per check would make the fastest growing table grow on every page view.
Testing it.
Because everything goes through one registry, the tests are generic:
- for every action, a dry-run doesn't change the database (snapshot before and after, through both the header and the batch);
- dry-run and the real call give consistent verdicts. This is generated from the registry, not written by hand;
- completeness: an action key without a route, or a route that isn't in the registry or the explicit public list, fails the test run.
The first two prove the mechanism works for what is in the registry. The third proves the registry has everything it should.
Was it worth it?
Mostly yes. Adding a role or a limit now means touching guards and the registry, not hunting through components. The UI can always say why something isn't available, and that's the part users notice.
The cost is one extra request per page (batched) and some discipline: every new action needs a registry entry. Writing a test that fails when you forget it is what keeps that discipline from being a promise.
The app is live at daihon.app: open a dialogue as a guest, finish three, and you'll see the disabled Start button explaining itself.
Thank you for reading. Feedback is highly appreciated!

Top comments (0)