Terms change. The question nobody enjoys answering is what should happen to the accounts that are already signed in.
The common answers are all a bit wrong. A blocking modal with an Agree button interrupts someone in the middle of their work to collect a click that proves nothing, and if they do not click it you have to decide whether to lock them out of a product they are paying for. An email nobody opens is not notice. Silence is not notice either.
What Nakodo does is show a small notice under the header, say what changed and when, link the documents, and record the acceptance as the notice appears. There is nothing to click, because the thing being recorded is not a click. It is the fact that the person read a notice saying "by continuing to use Nakodo, you accept them" and then continued to use Nakodo.
This post is about the plumbing that makes that honest rather than hand-wavy.
Two constants, and three places the version is written
src/lib/legal.ts is 43 lines and holds every fact about who runs the app:
export const LEGAL = {
company: PUBLISHER.name,
registeredIn: PUBLISHER.registeredIn,
// A company's website must show its registered number, registered office
// and the part of the UK it's registered in (gov.uk, "Running a limited
// company: signs, stationery and promotional material"). The terms and the
// privacy notice show them.
companyNumber: PUBLISHER.companyNumber as string | null,
// The registered office, also the address for legal notices. Until it's
// set, the pages leave it out.
address: null as string | null,
tradingName: BRAND.name,
email: "hello@nakodo.app",
law: "England and Wales",
regulator: { name: "the Information Commissioner's Office (ICO)", url: "https://ico.org.uk/make-a-complaint/" },
} as const;
export const TERMS_VERSION = "2026-10-17";
export const UPDATED = "17 October 2026";
export const LEGAL_DOCS = [
{ href: "/terms", label: "terms of service" },
{ href: "/privacy", label: "privacy notice" },
] as const;
Those string | null types are load-bearing. The registered office is not set yet, so the terms page renders the office sentence only when there is one (LEGAL.address && ...) and simply says less, rather than printing an empty sentence or the word "null". The company number is set, which is why the terms page opens with "Nakodo is run by CogniPrep Ltd, a company registered in England and Wales (company number 17407266)" and the privacy notice says the same thing in its own voice. One COMPANY template literal builds that clause; nothing in the app spells the company out by hand.
At sign-up the accepted version goes to three places, on purpose:
const acceptedAt = new Date();
const accepted = { termsVersion: TERMS_VERSION, termsAcceptedAt: acceptedAt.toISOString() };
const { data, error } = await supabase.auth.signUp({
...parsed.data,
options: { emailRedirectTo: await confirmUrl(next), data: { terms_version: ..., terms_accepted_at: ... } },
});
then a profiles row with the same two values, then an audit row (user.signed_up) carrying them as metadata. The auth user's metadata is the copy that survives anything we do to our own tables, the profile is the copy a page can read in one query, and the audit log is the copy with a timestamp we never update. Three copies of two values is not duplication worth deleting; it is the difference between believing your own records and hoping.
The whole decision is one strict inequality
{profile?.termsVersion !== TERMS_VERSION && <TermsNotice />}
That is the entire gate, in src/app/(app)/layout.tsx, which already loads the profile for the user menu, so the notice costs no extra query.
The optional chaining is the interesting half. An account created before we kept this column has null, so it sees the notice once, which is the correct outcome for someone who agreed to an older document. And if the profile itself were missing, undefined !== "2026-10-17" is also true, so the notice shows. Both unknown states fail towards telling the user something rather than towards silence.
The acceptance is written on arrival, not on interaction
export function TermsNotice() {
const [open, setOpen] = useState(true);
useEffect(() => {
startTransition(() => acceptTermsAction());
}, []);
if (!open) return null;
...
An effect that fires a Server Action and ignores the result looks like something a reviewer should object to. It is deliberate. The notice has no success state to render and nothing to wait for, so startTransition keeps it out of the render path entirely: the write happens, the component does not re-render, and the panel is interactive the whole time.
The cross in the corner is onClick={() => setOpen(false)} and nothing else. It hides the notice for this page view. It does not accept, refuse, or postpone anything, because by the time it is clickable the acceptance has already been recorded. That is also why the notice does not come back tomorrow, and why there is no "remind me later" to reason about.
The sentence it shows is built from LEGAL_DOCS, so adding a third document means adding one array entry rather than editing prose:
{LEGAL_DOCS.map((doc, i) => (
<Fragment key={doc.href}>
{i > 0 && (i === LEGAL_DOCS.length - 1 ? " and " : ", ")}
<Link href={doc.href} target="_blank">{doc.label}</Link>
</Fragment>
))}{" "}
changed on {UPDATED}. By continuing to use {BRAND.name}, you accept them.
The UPDATE is a compare and set, and the audit row depends on it
const [accepted] = await db
.update(profiles)
.set({ termsVersion: TERMS_VERSION, termsAcceptedAt: new Date() })
.where(and(eq(profiles.id, user.id), sql`${profiles.termsVersion} is distinct from ${TERMS_VERSION}`))
.returning({ id: profiles.id });
if (accepted) {
await audit({ action: "terms.accepted", userId: user.id, entityType: "user", entityId: user.id, metadata: { termsVersion: TERMS_VERSION } });
}
Two details carry this.
is distinct from rather than <>: the column is nullable, and null <> '2026-10-17' is null, which a WHERE clause treats as false. With <> the one group of accounts that most needs recording, the ones who accepted an older document and have nothing stored, would silently never be updated. is distinct from is the null-safe comparison, and it is one of the few places where SQL's three-valued logic quietly changes behaviour rather than throwing.
returning turns the UPDATE into a compare and set. React in development mounts effects twice; a double-submitted action, a second tab, or a fast reload can all call this more than once. The second call matches zero rows, accepted is undefined, and no audit row is written. The log therefore holds exactly one terms.accepted row per account per version, which is the property that makes it worth keeping at all. Writing the audit row first and the update second would have produced a log that counts tabs.
What it deliberately does not do
It does not prove anyone read anything. Nothing can. It does not block the app, because the people most likely to be mid-task are the ones with conversations running, and locking them out to collect a click would cost them real work for no legal gain. And it is separate from marketing consent, which lives on a different table keyed by email address, because "you accepted the current terms" and "you asked for product news" are not the same fact and should never be stored as one.
The version string is also a date, which means two constants have to move together when the documents change. That pairing is checked by a human and nothing else, so it is the honest weak point of the whole design: the gate is exact, the prose beside it is not.
You can see the output of all of this without an account. The terms and the privacy notice both print "Last updated 17 October 2026" and the company's registered details, from the same constants the notice reads. Change them and the notice fires for every signed-in account on the next page view, with no migration and no backfill.
Top comments (1)
Really clean take,
is distinctfrom detail is a good catch. One thing I keep coming back to though: the row says “accepted” the moment the notice renders, but the acceptance is supposed to be continuing to use the app. Would it be safer to log “notice shown” on render and “accepted” on the next action after it? Then the record matches what the notice actually says, which seems like what you’d want if it’s ever questioned.