How to Build an Internal Help Desk with Next.js and Supabase

The architecture behind a production internal request desk — Postgres row-level security for tenancy, SLA timers the database computes, and AI that helps but never blocks — on free tiers.

AAAayush AdhikariAugust 30, 2026 10 min read

To build an internal help desk with Next.js and Supabase, put three things in Postgres — who can see what (row-level security), when each request is due (an SLA timestamp set by a trigger), and the reporting math — and keep Next.js for screens and trusted server writes. Add AI only as a helper that suggests a category and priority, so a failed AI call never loses a request. Below is that architecture as we built it for LetRelay, with real free-tier limits.

What an internal help desk has to do

Every company runs on small internal requests: "my laptop won't boot", "I need access to the finance dashboard", "can we get six more design seats". In email and chat they get lost, answered twice, or answered never. A help desk turns each one into a record with an owner, a status and a deadline. If you are still deciding whether you need one, start with what an internal request desk is.

The minimum a real one needs:

  1. One front door. A form (and ideally chat or email intake) that creates a request.
  2. Routing. The request lands with the team that owns it — IT, HR, finance, facilities.
  3. A deadline. Each request type has a target time, and the queue sorts by what is closest to breaching.
  4. Isolation. People see their own requests; agents see their team's queue; one company never sees another's.
  5. Reporting. Where work piles up, which categories breach, how fast first responses are.

Everything below serves those five.

The stack, and why each piece

Layer Choice Why
UI + server code Next.js App Router Server Components for reads, Server Actions and Route Handlers for writes and AI calls — secrets never reach the browser
Database Supabase Postgres Row-level security, triggers, SQL analytics, pgvector for similarity search
Auth Supabase Auth via @supabase/ssr Cookie sessions that work in Server Components
Realtime Supabase Realtime The queue updates without polling
AI Gemini / Groq free tiers Light language tasks only: classify, draft, summarize

The organizing idea is to let the database do the heavy lifting. Authorization, SLA math and reporting are SQL. The app renders and validates; it is not where rules live.

Data model

Start with five tables. Every tenant-owned row carries an organization_id, and that one column is what makes multi-tenancy safe:

  • organizations — one row per company.
  • profiles — one per user, with organization_id and a role (requester, agent, admin).
  • teams — IT, HR, Finance…
  • categories — each with an sla_hours target (LetRelay defaults to 24).
  • requests — title, body, status, priority, team_id, category_id, assignee_id, sla_due_at, first_response_at.

Index every foreign key and every column you filter or sort on. For the queue that means at least (status, sla_due_at) and (team_id, status). A queue page that sorts ten thousand rows without an index is fast on day one and slow on day ninety.

Authorization lives in row-level security

This is the decision that matters most. With row-level security (RLS), Postgres filters every query by a policy, whichever code path issued it. A policy like "a user may read a request only if it belongs to their organization" means a forgotten .eq("organization_id", …) in app code cannot leak another tenant's data — the database refuses.

alter table public.requests enable row level security;
 
create policy requests_read on public.requests for select to authenticated
using (
  organization_id = public.current_user_org()
  and (
    requester_id = auth.uid()                -- my own requests
    or public.current_user_role() in ('agent', 'admin')
  )
);

Two details make this hold up in production:

  • Helper functions are security definer with a fixed search_path. current_user_org() reads the caller's profile; marking it security definer avoids a policy on profiles recursing into itself, and pinning search_path stops it being hijacked by a same-named function in another schema.
  • Roles are read from the database, never from the client. A role in a JWT claim the user can influence, or in a request body, is a suggestion. The profile row is the fact.

Supabase's own guide warns that tables without RLS are readable through the public API key, so turn it on for every table — including ones "only the server touches". We go deeper on the trade-off in row-level security vs app-layer authorization and on the tenancy pattern in the multi-tenant RLS guide.

Auth in the App Router

Use two Supabase clients from @supabase/ssr: a browser client (anon key only) and a server client that reads and writes cookies. A proxy (proxy.ts in Next.js 16, middleware.ts before that) refreshes the session on each request. Supabase's docs are blunt about one trap: never trust getSession() in server code, because it reads the cookie without revalidating it; use getClaims() or getUser(), which verify the token. The full setup is in Next.js App Router auth with Supabase.

The service-role key bypasses RLS entirely. Keep it server-only — never in a NEXT_PUBLIC_ variable — and use it for the few writes that genuinely must cross the user's permissions, such as creating an organization at sign-up.

SLA timers the database computes

Each category has an sla_hours target. When a request gets a category, a trigger stamps its deadline:

create or replace function public.set_sla_due()
returns trigger language plpgsql as $$
declare h int;
begin
  if new.category_id is not null and new.sla_due_at is null then
    select sla_hours into h from public.categories where id = new.category_id;
    if h is not null then
      new.sla_due_at := coalesce(new.created_at, now()) + make_interval(hours => h);
    end if;
  end if;
  return new;
end $$;

There is no background job "checking" SLAs. The queue is a query — order by sla_due_at — and "breaching soon" is sla_due_at < now() + interval '1 hour', served by the index. The clock is the database's clock, so a request filed at 23:59 in one timezone and read at 00:01 in another has one deadline, not two. The SLA tracking guide covers pausing the clock, business hours and escalation.

AI as a helper, never a gatekeeper

When a request arrives, a language model reads it and suggests a category, a priority and similar past answers. Three rules keep this safe:

  1. The request saves first. The AI runs after the row exists. If the model times out, rate-limits or returns nonsense, the request simply waits in human triage.
  2. Structured output, validated. Ask for JSON against a schema, then validate it again with Zod. A category the model invents is rejected, not created.
  3. A confidence floor. By default LetRelay applies a suggestion automatically only at confidence ≥ 0.8 (each organization can tune it); below that it is shown as a hint.

On free keys this is not optional. Groq's free tier documents 30 requests per minute, 8,000 tokens per minute and 200,000 tokens per day for openai/gpt-oss-20b, and returns HTTP 429 with a retry-after header past that. Gemini's free limits are per account and shown in AI Studio, with daily quotas resetting at midnight Pacific. In our own measurements a routing prompt costs about 3,100 tokens, which is roughly 65 routed questions a day on one Groq model before failover. We describe the failover gateway in a free AI gateway with Gemini and Groq failover and the philosophy in AI that degrades gracefully.

Realtime without polling

Agents should see a new request appear without refreshing. Supabase Realtime can stream row changes, but a change stream must still respect RLS — each subscriber only receives rows its policies allow. For data that is gated by membership in something other than the row itself (a chat room, say), broadcasting on a channel after a trusted write is simpler and cheaper than making the change stream evaluate complex policies for every subscriber.

Analytics in SQL, not a BI tool

Managers want: median first-response time, breach rate by category, backlog by team, and where requests wait longest. All of it is group by over requests, and Postgres answers it in milliseconds with the indexes above. We expose each report as a security invoker SQL function so it runs with the caller's RLS — an agent sees their team's numbers, an admin sees the organization's. See building an analytics dashboard with plain Postgres.

What the free tiers actually give you

Supabase's Free plan (as listed on its pricing page) includes a 500 MB database, 1 GB of file storage, 50,000 monthly active users, 5 GB of egress, 200 concurrent Realtime connections and 2 million Realtime messages a month, with up to two active projects; free projects pause after a week of inactivity. Text is small: LetRelay's entire blog — 34 posts with metadata — occupied under 0.5 MB of that database.

Hosting needs a caveat. Vercel's Hobby plan is free but, per Vercel's own documentation, restricted to non-commercial, personal use. It is fine for building and demos; a product that sells subscriptions or shows ads needs a plan or host that allows commercial use. We cover the whole budget in SaaS architecture on free tiers.

A build order that works

  1. Tables, enums, indexes and RLS policies, in migrations.
  2. Auth: sign-up creates the organization and the first admin profile.
  3. The request form (Zod-validated Server Action) and the requester's "my requests" list.
  4. The agent queue sorted by sla_due_at, with assignment and status changes.
  5. SLA trigger, first-response stamping and breach highlighting.
  6. AI suggestions behind the "save first" rule.
  7. Reports as SQL functions.
  8. Realtime updates last — the app should be correct without them.

Test the security model, not just the screens: sign in as a user from organization A and try to read, update and delete organization B's rows through the API. Every one of those should fail at the database.

FAQ

Can I build a help desk on Supabase's free plan?

Yes, for a small team. The limits that bite first are usually the 500 MB database once you store attachments inside it (keep files in Storage instead) and projects pausing after a week without activity. Text requests are tiny; thousands fit easily.

Why use row-level security instead of checking permissions in API routes?

Because API checks must be remembered on every route, and RLS is enforced on every query. With RLS a missed check in app code does not become a data leak. You still hide buttons in the UI to match roles, but the database is the real gate.

What happens to a request when the AI is down?

Nothing bad. The request is saved before any AI call; if classification fails it stays unclassified and appears in the human triage queue. The AI only ever adds suggestions.

Do I need Next.js for this?

No — any framework with server-side code can do it. Next.js App Router is convenient because Server Components read data with the user's session and Server Actions keep secret keys on the server.

Is Vercel's free plan OK for a commercial help desk?

Vercel documents the Hobby plan as non-commercial, personal use only. For a paid product, use a commercial plan or another host whose terms allow it.

Sources

AA
Aayush Adhikari

Building Relay — the internal request desk with AI triage and SLA tracking.

Run your internal requests on LetRelay

AI triage, SLA-tracked queues, and bottleneck analytics — the help desk your team actually likes. Free to start.

Try LetRelay free No credit card required
Ad spaceYour Google AdSense unit shows here once approved.

Keep reading