Nakodo has an operator panel at /admin: six screens for one person. It is 17 files and 2,230 lines, which is 3% of the 72,683 lines under src, and the most useful decision in it is a refusal.
The Inbox screen lists everything that has arrived at the catch-all address for the domain, with who sent it, which category the address sorted into, whether it was forwarded, and the error if it was not. It has no reply box, and the comment at the top of the file says why:
// What arrived at the catch-all and where it went. The mail itself is answered
// from the forwarding inbox, not here: a second place to reply from is how one
// customer gets two different answers.
That line is the whole design rule of the panel, and it is worth more than any of the code under it. Mail to the domain is forwarded to a normal mailbox with the sender as Reply-To, so answering it is an ordinary reply from an ordinary mail client, with the history, the signature and the search that a mail client already has. Building a reply box here would create a second outbox with none of that, and the first time I answered the same person from both, they would get two different answers with two different threads.
So the screen is a record, not an inbox. It exists to answer one question that a mail client genuinely cannot: did it arrive and was it forwarded.
const mail = await recentInbound(60);
const failed = mail.filter((m) => !m.forwardedTo && !m.spam).length;
That failed count is the reason the page is worth having. Forwarding is a webhook, a parse and a send, and all three can fail quietly; a mail client cannot show you the message it never received. The listing shows a spam-flagged message as Discarded rather than hiding it, because the only way to notice an over-eager filter is to see what it caught.
Where the panel does act, it owns the record
The counter example is the Support screen, which does have an answer box:
<Textarea placeholder="Answer them. This goes out as an email as well as into the thread." aria-label="Your answer" rows={6} />
<Button disabled={pending || tooShort} onClick={() => run(() => answerSupportAction({ reference, body }), () => setBody(""))}>
The difference is ownership, not convenience. A support conversation is a row in our own database with a reference, a status and a transcript; the customer sees the same thread in the app. Answering it here writes to the record that is already the source of truth and sends the email as a side effect. Answering a forwarded message here would write to nothing.
The same screen also shows what the status buttons deliberately omit:
// Where this one can go from here. "answered" is what sending does, so it
// is never a button.
const moves: SupportStatus[] = status === "closed" || status === "resolved" ? ["open"] : ["resolved", "closed"];
Four statuses exist in that enum, and the screen offers one or two moves, because "answered" is a consequence of an action rather than an action.
56 lines of design system
Six screens that feel like one tool, with no component library of their own. The entire visual vocabulary of the panel is one 56-line file with three exports: a page header, a bordered section with a title and an optional description, and a stat tile.
// A number with its label. The number is the thing being read, so it is the
// only part at size.
export function Stat({ label, value, note }: { label: string; value: string | number; note?: string }) {
return (
<div className="rounded-md border px-3 py-2">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-xl leading-7 font-semibold tabular-nums">{typeof value === "number" ? value.toLocaleString("en-US") : value}</p>
{note && <p className="text-xs text-muted-foreground">{note}</p>}
</div>
);
}
Everything else is a table. Which produced the fact I did not expect when I counted the imports of our Table component: it has eight importers, and seven of them are /admin pages. The one non-admin importer is the campaign results table behind sign-in.
Meanwhile the public site is full of tables. I fetched all 76 URLs in the sitemap and counted: 53 <table> elements across 47 pages, and not one of them carries the data-slot="table" attribute that our component stamps on its root. Every comparison table a visitor can read, such as the ones in the guides or the plan comparison on pricing, is plain markup from the content renderer, with its own <th scope> handling and its own horizontal scroller.
$ curl -s https://nakodo.app/guides/how-much-to-pay-youtubers | grep -c '<table'
1
$ curl -s https://nakodo.app/guides/how-much-to-pay-youtubers | grep -c 'data-slot="table"'
0
So the generic table component the CLI wrote is, in practice, operator tooling. It was never wrong for the job it has; it is just a reminder that a design system component and a content element are two different things that happen to share a tag name.
Four of the 17 files are client components: the nav, the support answer box, the account edit forms and the mailing composer. Everything else is a server component that awaits a read model in src/lib/admin/queries.ts and prints it. Three files are Server Actions and one is a route handler that streams a CSV.
The gate, and the one thing it leaves open
The layout is four lines of configuration before it is anything else:
export const metadata = {
title: { template: "%s ยท Admin", default: "Admin" },
robots: { index: false, follow: false, nocache: true },
};
export const dynamic = "force-dynamic";
export default async function AdminLayout({ children }: LayoutProps<"/admin">) {
const admin = await requireAdmin();
requireAdmin() is a hardcoded allowlist of one address, it redirects a signed-out visitor to sign in, and it calls notFound() for a signed-in account that is not on the list, so the panel never confirms its own existence to someone who merely has an account. Every page and every action calls it again, because a layout does not protect a Server Action.
Here is the gap I found while writing this. The robots metadata above is unreachable by any crawler. Our robots.txt is generated from a shared list of private prefixes:
export const PRIVATE_PREFIXES = ["/campaigns", "/settings", "/onboarding", "/billing", "/api/"];
/admin is not in it:
$ curl -s https://nakodo.app/robots.txt
User-Agent: *
Allow: /
Disallow: /campaigns
Disallow: /settings
Disallow: /onboarding
Disallow: /billing
Disallow: /api/
Disallow: /auth/
So /admin is allowed by robots.txt, and it is also not in the public-path allowlist, which means a crawler that requests it gets a redirect before any HTML exists:
$ curl -sI https://nakodo.app/admin | head -2
HTTP/2 307
location: /login?next=%2Fadmin
The outcome is fine. The reasoning is not: the panel is unindexable because of the auth redirect, and the noindex that was written to make it unindexable is served to nobody. Two mechanisms, one of which does all the work, and the list that was supposed to tie them together is missing an entry. Adding /admin to PRIVATE_PREFIXES is a one-word change and the test next to it would then assert the prefix is not public.
The other thing missing is 19 files
The app has 19 loading.tsx files, one for every page in the signed-in product, so nothing in it ever shows a blank frame. /admin has none, across all six screens, each of which is force-dynamic and awaits several counts over the whole database. The operator stares at the previous page while the next one loads.
That is a defensible place to spend nothing, and it is also the clearest statement of what the panel is: not a product, a set of reports with a 404 in front of them, where the only screens that let you act are the ones whose records we actually own.
Top comments (0)