· 3 min read · Next.js, Supabase, RBAC, TypeScript

Making users acknowledge before they proceed: an acknowledgement gate in Next.js and Supabase

How to lock a dashboard until users acknowledge important notices, with automatic expiry, server-side checks and Supabase row-level security, inspired by NOTAMs in an airline portal.

Some information has to be read before anything else happens. In aviation it's NOTAMs, notices to airmen about things like closed runways or changed procedures. In a business it might be a new policy, a safety briefing or updated terms. The usual approach is an email and a hope that people read it. A better approach is to have the system enforce it.

In the Aer Lingus Virtual operations portal, which I built in Next.js and TypeScript with Supabase, the NOTAM module does exactly that. NOTAMs expire automatically, and the dashboard stays locked until each pilot acknowledges them. This guide explains how to build that pattern, an acknowledgement gate, in a Next.js and Supabase application.

What the gate needs to do

  • When a notice is published, every affected user must acknowledge it before using the app
  • Each acknowledgement is recorded: who, which notice, and when
  • Expired notices stop blocking anyone, automatically
  • The gate can't be bypassed by closing a modal or editing the page in the browser
  • Only the right roles can publish notices

A simple data model

Two tables are enough: one for the notices, and one recording acknowledgements. The composite primary key means a user can acknowledge each notice only once.

create table notices (
  id           uuid primary key default gen_random_uuid(),
  title        text not null,
  body         text not null,
  published_at timestamptz not null default now(),
  expires_at   timestamptz not null
);

create table acknowledgements (
  user_id         uuid references auth.users not null,
  notice_id       uuid references notices on delete cascade not null,
  acknowledged_at timestamptz not null default now(),
  primary key (user_id, notice_id)
);

Expiry without a scheduled job

You don't need a cron job to expire notices. Define "active" in the query itself: a notice is active if it has been published and its expiry is in the future. The moment expires_at passes, the notice simply stops appearing, with nothing to schedule, run or clean up.

const now = new Date().toISOString();

const { data: active } = await supabase
  .from("notices")
  .select("id, title, body, expires_at")
  .lte("published_at", now)
  .gt("expires_at", now);

Find what the user hasn't acknowledged yet

Compare the active notices with the user's acknowledgements. Whatever is left is what stands between them and the dashboard.

const { data: acks } = await supabase
  .from("acknowledgements")
  .select("notice_id")
  .eq("user_id", user.id);

const done = new Set(acks?.map((a) => a.notice_id));
const outstanding = (active ?? []).filter((n) => !done.has(n.id));

For larger apps, this is a good candidate for a database view or a Postgres function, so the logic lives in one place and runs in a single query.

Enforce the gate on the server

This is the part that matters most. If the check only runs in the browser, as a modal over the dashboard, a user can close it with the developer tools, and the dashboard's data has already been sent to them anyway. The check has to happen on the server, before the dashboard renders.

In the Next.js App Router, a layout that wraps the protected area is a natural place for it:

// app/(dashboard)/layout.tsx
export default async function DashboardLayout({ children }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const outstanding = await getOutstandingNotices(supabase, user.id);
  if (outstanding.length > 0) redirect("/notices");

  return children;
}

The /notices page lists what needs reading, with an acknowledge button for each one. When the list is empty, the user is sent back to the dashboard.

Record the acknowledgement with a server action

"use server";

export async function acknowledge(noticeId: string) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error("Not signed in");

  await supabase
    .from("acknowledgements")
    .upsert({ user_id: user.id, notice_id: noticeId });

  revalidatePath("/notices");
}

Using upsert makes a double click harmless: acknowledging twice has the same result as acknowledging once.

Back it up with row-level security

Application code is one layer of defence; the database should be another. Supabase exposes your tables through an API, so row-level security (RLS) policies decide what each user may do, whatever the client sends.

alter table acknowledgements enable row level security;

-- Users can only record and read their own acknowledgements
create policy "own acknowledgements"
  on acknowledgements for all
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

Publishing notices should be limited to the roles that are allowed to do it, with a matching policy on the notices table. That's the same role-based access control (RBAC) that drives the rest of a portal like this, where pilots, staff and administrators each see a different, role-gated dashboard.

Make it pleasant, not punishing

A gate is an interruption, so keep it respectful of people's time:

  • Show notices in full on one page, not buried behind links
  • Let people acknowledge each one individually, and show how many are left
  • Keep notices short, with the expiry date visible so people know how long it applies
  • Let users see notices they've already acknowledged, for reference

Where this pattern fits

The same design works well beyond aviation: acknowledging updated terms of service, reading a safety procedure before starting a shift, confirming a policy change, or completing a briefing before accessing sensitive records.

In the Aer Lingus Virtual portal it sits alongside other rules the system enforces on its own: email-verified registration with admin approval, OCC reports that escalate automatically after 60 minutes without action, comments that lock after 24 hours, and vacancies that delete themselves when their deadline passes.

If your organisation has processes like these that currently run on reminders and good intentions, let's talk about building them into your system.

Case studyAer Lingus Virtual: An operations portal with rules built in

Keep reading