PolicykitDocs

Notices and consent

Announce a pending policy revision, deliver the notice emails, and re-gate consent - the three jobs a product owes when a policy changes.

5 min readUpdated 11 August 2026
Notices and consent

When a policy revision is published, your product owes three things: a banner announcing it, a notice delivered to each user, and - for a reconsent revision - a fresh consent gate. Policykit answers each question; the delivery and storage stay yours.

Prerequisites: a POLICIES array of configured Policy instances (see getting started) and revisions carrying a notice tier (see writing a revision).

Announce the pending revision

PolicyBanner is a headless server component. It takes policies (required), an optional now (defaults to new Date(), and reduced to its UTC day), and a render-prop children called only when there is something to announce - so your callback never handles an empty case. It renders null otherwise. It reads the filesystem, so it must never appear inside a "use client" tree, and a broken policy corpus throws PolicyValidationError rather than quietly announcing nothing.

// app/[locale]/layout.tsx
import type { ReactNode } from "react";
import { PolicyBanner } from "@daanvandenbergh/policykit/react";
import { POLICIES } from "@/content/policies";
import { MyLink } from "@/components/MyLink";

export default function LocaleLayout({ children }: { children: ReactNode }) {
    return (
        <>
            <PolicyBanner policies={POLICIES}>
                {({ policy, revision }) => (
                    <aside className="banner">
                        A new version of {policy.slug} takes effect on {revision.effectiveFrom}.{" "}
                        <MyLink href={`/legal/${policy.slug}`}>Read it</MyLink>
                    </aside>
                )}
            </PolicyBanner>
            {children}
        </>
    );
}

It announces the revision taking effect soonest across the policies you pass. On an effectiveFrom tie, the first policy in argument order wins. Outside React - an in-app banner API, a mail digest header - call the same core function directly; the component is a thin wrapper around it:

import { pendingNotice } from "@daanvandenbergh/policykit";

const announcement = pendingNotice(POLICIES, new Date()); // { policy, revision } | undefined

For a site-wide banner, do not hand-roll this as policies.map((p) => p.pending(now)).filter(...). pending() answers "what takes effect next for this document" regardless of tier, so it returns a notice: "none" revision - announcing which contradicts the recorded decision that no notice is owed - and that revision then masks any later notice-owing revision behind it. pendingNotice skips "none" revisions and keeps scanning the policy, so a pending "none" can never swallow a notify or reconsent sitting after it.

On a single policy's own page the opposite holds: policy.pending(now) is the right call, because there you are labelling that document's next version whatever its tier, not deciding what notice is owed. Rendering policies shows that pattern.

Deliver the notices

noticeQueue(policies, { now, horizonDays? }) returns every revision that owes notice (notice !== "none"), will actually bind, and whose effectiveFrom is within the horizon or still in the future - ordered by policy, then revision ascending. Dedupe delivery per (user, policy, revision.revision):

import { noticeQueue } from "@daanvandenbergh/policykit";

for (const { policy, revision } of noticeQueue(POLICIES, { now: new Date() })) {
    await notifyUsersOnce(policy.slug, revision.revision, revision.effectiveFrom);
}

For one document at a time - a dashboard row, an admin view - use policy.owedNotices({ now, horizonDays? }), which returns the revisions only and has identical semantics.

horizonDays defaults to the exported DEFAULT_NOTICE_HORIZON_DAYS, which is 60. The boundary is inclusive: a revision exactly 60 days old is still queued that day and gone the next. A non-finite or negative horizonDays throws a TypeError rather than silently dropping owed notices.

The horizon is load-bearing. Your dedupe rows expire on a TTL; if the horizon is longer than that TTL, every historical revision is re-queued each time its dedupe row is swept, re-notifying every user forever on a schedule nobody watches. That is why the constant is exported - assert it against your own TTL:

import { expect, it } from "vitest";
import { DEFAULT_NOTICE_HORIZON_DAYS } from "@daanvandenbergh/policykit";

it("the notice horizon stays inside the dedupe TTL", () => {
    expect(DEFAULT_NOTICE_HORIZON_DAYS).toBeLessThan(NOTIFICATION_TTL_DAYS);
});

requiredConsentRevision(policies, now) returns the revision string a user must have accepted to count as consented: the max effective revision that is either a policy's baseline (the first revision that ever binds) or carries notice: "reconsent". It returns "" when nothing is in force yet, which correctly means nobody owes consent.

import { requiredConsentRevision } from "@daanvandenbergh/policykit";

const required = requiredConsentRevision(POLICIES, new Date());
// "" means nothing is in force yet, so nobody owes consent; ?? "" keeps a never-accepted
// user (undefined stamp) from failing the comparison for the wrong reason.
const consented = required === "" || (user.acceptedPolicyRevision ?? "") >= required;

A notify revision never moves this value - notify means existing consent stands. A reconsent revision moves it only from its effectiveFrom day, not from the day it is published - and that boundary is UTC midnight, identically for every user and every server, like every other date comparison in the package (how it works covers the convention).

The gate is one joint threshold across the policies you pass. A reconsent whose revision string is older than a sibling policy's newer baseline or reconsent is subsumed by it, so users re-prompt once against the joint max rather than once per policy. If you need strictly per-policy reconsent tracking, call this per policy - requiredConsentRevision([termsPolicy], now) - and store one stamp each.

On acceptance, stamp the function's own return value, evaluated at acceptance time:

const accepted = requiredConsentRevision(POLICIES, new Date());

Stamping anything else breaks. One policy's revision lags the joint gate and re-prompts forever; the max effective revision across policies can run ahead of a sibling's pending reconsent, silently satisfying a gate that was never met. Revisions are zero-padded ISO strings, so string order is date order - compare them with >= and never convert them to Date.

Superseded revisions are excluded everywhere

A revision superseded before it ever bound is never announced, never queued, and never moves the consent gate - see how it works for why. What that costs you as an author: if the superseding revision still owes the superseded one's reconsent, record reconsent on the superseding revision itself, or the re-prompt is silently dropped.

  • API reference - every signature, default, and throw condition on this page.
Was this page helpful?