Angelo Dini, Switzerland. Head of Product at Divio, co-founder of devguard.

Building a Helpdesk as a Library

How Better Helpdesk works inside a Next.js app: one Request handler, four databases behind one adapter, realtime over Postgres NOTIFY and jobs with no queue.

By 16 min read

Article
Article 079 of 79
Category
Develop
Steps
9
Code samples
TypeScript, SQL

No second anything

Every helpdesk I have used was a place you go to. It has its own login, its own database and its own idea of who your customers are, and you spend the first week teaching it things your app already knows. We wanted one that is a dependency instead: something you install, mount on a route and forget is a separate product.

A counter bell taken apart: the round base with its striker, the dome lifted off it, and the push button raised in orange above the dome.
Fig. 079 A counter bell taken apart: the round base with its striker, the dome lifted off it, and the push button raised in orange above the dome.

That is Better Helpdesk, an MIT-licensed support inbox, ticketing and small CRM that we built at devguard, where I’m a co-founder. You npm install better-helpdesk, and it runs in your Next.js process, in your database and behind your login. I wrote about the why on the devguard blog, in We built the helpdesk we wanted: per-seat pricing, customer conversations on somebody else’s servers, and self-hosted alternatives that are a whole second application to operate.

This post is the other half, the how. “It’s a library” sounds like a packaging decision. It turned out to be the constraint that decided nearly every piece of the design, because a library cannot bring any of the things a helpdesk normally leans on. The roadmap has a line I keep coming back to: no second process, database or queue. So no Redis, no worker, no websocket server and no users table. What follows is what we built in their place.

What you mount

The whole integration is one route handler, one React component for the team and one widget for customers:

TypeScript
// app/api/helpdesk/[...slug]/route.ts
import { helpdesk } from '@/lib/helpdesk';

const handle = (request: Request) => helpdesk.handler(request);
export {
  handle as GET,
  handle as POST,
  handle as PATCH,
  handle as PUT,
  handle as DELETE,
  handle as OPTIONS,
};

helpdesk.handler is a plain function from a Request to a Response. There is no Next.js import anywhere in it, which is why the repository also has working examples for Hono and SvelteKit: anything that speaks the Fetch API can mount it. Inside sits a small router of our own (a list of define('GET', 'widget/events', …) calls) and behind that a service layer, a store and a database adapter.

Everything it serves lives under one basePath:

Who Uses Talks to
Visitors and customers <HelpdeskWidget /> or widget.js {basePath}/widget/*
The support team <HelpdeskAdmin /> {basePath}/agent/*
Your mail relay a POST with each raw inbound email {basePath}/inbound
Your scheduler helpdesk.runJobs() or a POST {basePath}/jobs

The piece I like most about this shape has nothing to do with HTTP. Because the helpdesk is an object in your process, your own server code calls it directly. A billing webhook can open a conversation when a payment fails with helpdesk.createConversation(…), and an upgrade lands on the customer’s timeline with helpdesk.track(…). No API token to issue and rotate, no network hop. The roadmap rules out a public REST API for exactly this reason: if you want one, it’s a route in your app behind your own auth.

Who is asking

A library has no business storing passwords, so Better Helpdesk stores none. No sessions either. You write one function:

TypeScript
identify: async request => {
  const session = await getSession(request); // however your app does it
  if (!session) return null; // an anonymous visitor
  return {
    user: {
      id: session.user.id,
      email: session.user.email,
      emailVerified: true,
      name: session.user.name,
    },
    orgs: session.orgs.map(org => ({ id: org.id, name: org.name })),
    isAgent: session.user.role === 'support',
  };
},

isAgent is the only thing that opens the agent UI. The first request from someone with that flag writes an agent row keyed by your user id, and that’s the whole onboarding of a new colleague. The repository has worked identify functions for Better Auth, Auth.js, Clerk and Supabase Auth.

That covers the same origin. A widget on a marketing site at another domain has no session cookie to read, so there the host signs an identity token: an HS256 JWT that goes into an x-helpdesk-identity header. The implementation is 84 lines on top of node:crypto, with createHmac for the signature and timingSafeEqual for the comparison, and Zod for the claims. No JWT library. Two details in it are deliberate:

  • The signature is compared before the header is even parsed, so nothing attacker-controlled is interpreted first, and any algorithm other than HS256 is refused afterwards.
  • verifyIdentityToken returns an identity with isAgent: false hard-coded. A token can vouch for a customer. It can never make an agent, no matter what somebody puts in the claims.

Anonymous visitors get a third route in: 32 random bytes as a visitor token, of which the database only ever sees the hash.

Four databases, one model

The first versions were Postgres only, with migrations generated by Drizzle. You can still see the fossils in the repository: thirteen files with names like 0003_thin_marvel_boy.sql and 0012_cold_felicia_hardy.sql. The package went to npm on 28 September, and on 7 October one pull request moved the database layer onto Kysely behind an adapter of our own. MySQL, SQLite and SQL Server adapters landed the same day.

The reason is the premise of the project. “Runs in the database you already have” is a hollow promise if it only means Postgres.

The tables are described once, in src/db/model.ts, as plain data: field kinds, nullability, defaults, references. The store never writes SQL against them. It talks to a deliberately small contract:

TypeScript
export type AdapterInput = {
  id: string;
  supports: { dates: boolean; booleans: boolean; json: boolean };
  create(model: ModelName, data: Data): Promise<void>;
  findMany(model: ModelName, query: FindQuery): Promise<Data[]>;
  count(model: ModelName, where?: Where): Promise<number>;
  updateMany(
    model: ModelName,
    where: Where | undefined,
    set: Record<string, unknown>
  ): Promise<number>;
  deleteMany(model: ModelName, where: Where | undefined): Promise<number>;
  transaction<T>(fn: (tx: AdapterInput) => Promise<T>): Promise<T>;
  capabilities?: Capabilities;
};

Five CRUD methods and a transaction. A Where is a small tree of { field, op, value } nodes with and, or, not and a subselect, and that is all the query language the store gets. If you have used Prisma the shape will look familiar, only much poorer on purpose.

The supports flags are where the databases stop agreeing. SQLite has no boolean and no date type, so a wrapper converts on the way in and out: Date objects become ISO strings, booleans become 1 and 0, JSON becomes text. The adapter author declares what the driver handles and the wrapper does the rest.

What the lowest common denominator cost

Three things had to change to make that contract honest.

Tags were text[] columns, which only Postgres has. They moved into tag tables, with a migration that unnests the arrays and keeps their order.

Conversation references such as ACME-1042 came from a Postgres sequence. Now there is a counter table with one row, read with a row lock inside the transaction that creates the conversation and then incremented. The migration seeds it from wherever the sequence had stopped, so nobody’s numbering jumped.

And ids and timestamps are set by the app instead of by column defaults. That one has a wrinkle: two rows written in the same millisecond have no order. So clock() hands out the current time but never the same value twice, nudging forward by a millisecond when it must, and follows the system clock back only if that is set back by more than a second.

Some things are simply better with what one database offers, and for those the adapter can declare capabilities. Inbox search on Postgres uses websearch_to_tsquery over a search column. Without the capability the store falls back to the CRUD methods and a substring match: slower, same results. An adapter is correct with zero capabilities and faster with each one it adds.

The part I’d point other library authors at is better-helpdesk/testing. It exports runAdapterTests, the contract as a Vitest suite, so someone writing an adapter for a database we have never touched can prove it behaves before a single conversation depends on it.

Where the database has schemas (Postgres, SQL Server) the tables sit in a helpdesk schema; where it has none they carry a helpdesk_ prefix. Either way they are ordinary tables next to yours, which is the point:

SQL
select o.plan, count(*) as open
from helpdesk.conversation c
join helpdesk.company co on co.id = c.company_id
join organizations o on o.id::text = co.external_org_id  -- your table
where c.status = 'open'
group by o.plan;

Realtime without a websocket server

A reply should appear in the widget without a refresh. The usual answer is a websocket service, and a websocket service is a second process.

So both UIs poll, always. Polling is the floor, and it works on every host including the serverless ones. On top of it sits an optional realtime adapter that serves Server-Sent Events from the same handler:

TypeScript
import { buildHelpdesk, postgresAdapter, postgresRealtime } from 'better-helpdesk';

export const helpdesk = buildHelpdesk({
  db: postgresAdapter({ pool }),
  realtime: postgresRealtime({ pool }),
  // …
});

memoryRealtime() is a Set of listeners and reaches the streams of one process. postgresRealtime() publishes with pg_notify and holds one pooled connection on LISTEN helpdesk_changes for as long as anyone is subscribed, then gives it back. A reply written on one instance reaches a browser connected to another, and the message bus is the database you already had.

An event carries almost nothing: a conversation id and whether it was a message, a change or typing. The client reloads what it shows through the normal API, so permissions are checked in one place and the stream cannot leak the body of an internal note.

Three small things in there cost more time than the rest together:

  1. A stream ends itself after five minutes and the client reconnects. That keeps a serverless function inside its time limit and lets proxies recycle connections.
  2. The response sends x-accel-buffering: no. nginx buffers responses unless told otherwise, which holds every event back until the buffer fills. That is a fun afternoon if you don’t know it.
  3. The client reads the stream with fetch and not EventSource, because EventSource cannot send custom headers and a widget on another origin signs in with exactly those.

If the listening connection drops, notifications are lost until it reconnects. Nothing compensates for that except the polling underneath, and that is fine. It’s why the polling never went away.

Jobs without a queue

Reminder emails, snoozes that wake up on Thursday, SLA warnings, retention: a helpdesk has plenty of work that happens later. The usual answer here is Redis and a worker.

Ours is a job table and a function. helpdesk.runJobs() runs on whatever scheduler you already have, or you POST to {basePath}/jobs with a bearer secret from a cron. One run wakes due snoozes, sends due reminders and SLA notices, applies retention and then works through queued jobs inside a time budget.

Two runs at once must not send the same email twice. On Postgres alone I would reach for FOR UPDATE SKIP LOCKED, but the contract has to hold on four databases, so the claim is a compare-and-set through the same updateMany everything else uses:

TypeScript
const won = await adapter.updateMany(
  'job',
  and(
    eq('id', job.id),
    eq('attempts', job.attempts),
    at('lockedUntil', job.lockedUntil)
  ),
  { lockedUntil, attempts: { increment: 1 } }
);

If the row still looks the way it did when we read it, one row changes and the job is ours. If another run got there first, zero rows change and we move on to the next due job. The claim expires after two minutes, so the jobs of a run that crashed come back on their own. A failing job retries with exponential backoff and is parked after five attempts.

Rate limits work the same way: a rate_limit table with one row per key and hour, incremented in place. It won’t win a benchmark against Redis. It also doesn’t need Redis.

The widget and the browser

The widget is a custom element, <helpdesk-widget>, with a React wrapper for hosts that are React and a standalone widget.js for sites that are not. It renders into a shadow root so the host’s CSS and the widget’s never meet, and takes its look from --helpdesk-* custom properties, which do cross that boundary. It also listens for error and unhandledrejection on the page and keeps the last ten messages. A bug report arrives with them, plus the URL, viewport and locale, and the customer sees each item on the form and can strike any of them out before sending.

The agent UI is authenticated by your session cookie, which makes it a CSRF target. The guard is short enough to quote:

TypeScript
// Only a POST can come from a plain form; PATCH and DELETE always preflight.
const type = request.headers.get('content-type') ?? '';
if (request.method === 'POST' && !type.startsWith('application/json')) {
  throw new HelpdeskError(415, 'Expected application/json');
}
const origin = request.headers.get('origin');
if (!origin || origin === ownOrigin) return;
if (path.startsWith('widget/') && allowedOrigins.has(origin)) return;
throw new HelpdeskError(403, 'Cross-origin request refused');

Requiring JSON forces a CORS preflight, so a form on a hostile site cannot ride an agent’s cookie, and the origin check catches the rest. The cost is one config value you must get right: adminUrl has to be the URL your team really opens. Get it wrong and every reply is a 403. Ask me how I know. 🙃

The other trap is trailingSlash. Both UIs request paths with a trailing slash, and if Next.js redirects to add or remove one, a cross-origin widget fails, because a redirect on a preflight is an error. It’s in the install guide now.

For anonymous visitors there is one more hurdle that people never notice. When a form opens, the widget asks for a token: a timestamp with an HMAC over it, signed with a secret the helpdesk generates itself on first use and keeps in its setting table. A first message has to carry one back. A script therefore has to ask and wait for every message it posts, like a person would, and the hourly limits do the rest. Since 1.1.0 you can plug Turnstile, hCaptcha or reCAPTCHA in as an adapter if that isn’t enough.

Email, and not trusting the relay

Outbound email goes through an adapter you write around your own sender, so there’s little to say about it. Inbound is more interesting. Any relay that can POST a raw message to {basePath}/inbound works; the repository has a Cloudflare Email Worker and handlers for Mailgun and Postmark.

A reply finds its conversation by the message ids in In-Reply-To and References, or by a plus-addressed reference in the Reply-To (support+ACME-1042@…). The question that matters is whether to believe the From header, because believing it means a stranger can write into a customer’s conversation.

Most systems read the Authentication-Results header that the receiving server added. That trusts whatever sat in between. We verify DKIM ourselves with mailauth against the raw bytes, so the answer holds whatever relayed the message, as long as it arrived unmodified. Then come the checks I only learned about by reading how DKIM gets abused:

  • The signing domain has to align with the From domain, and there must be exactly one From header. The DKIM check reads the first and a mail parser reads the last, so a second one would let one domain’s signature vouch for another’s address.
  • The signature has to cover the From header at all. It is legal for it not to.
  • A signature with an l= body length limit is rejected, since everything after that length can be appended freely.
  • More than five signatures and we don’t start. Each one is a DNS lookup and a body hash, and real mail carries one or two. The whole check has a 15 second deadline.

Mail that fails isn’t thrown away. It reaches the team labelled as unverified, and it doesn’t get to speak in the thread as the customer it claims to be.

Shipping it

A package with server code, client components and a script-tag bundle needs three builds, and esbuild does all of them in a 90-line script: a Node build for the server entries, a browser build for the widget and the agent UI whose entries keep their 'use client', and a minified IIFE for widget.js. tsc emits the declarations, and a small pass afterwards adds .js to their relative imports, because NodeNext consumers cannot resolve the extensionless ones TypeScript leaves in.

There are four runtime dependencies: kysely, zod, mailparser and mailauth. React 19 and your database driver are peers. SQLite needs no driver at all, since it goes through Node’s own node:sqlite, which is also why the floor is Node 22.19.

The test that has earned its keep is the dull one. CI packs the tarball, installs it outside the repository and uses it, and builds the example app against that tarball on the current and the previous major of Next.js, with next@canary once a week. Everything else in the suite tests the source. This one tests what people actually download, and it is the one that fails when the exports map is wrong. The integration tests run against MySQL and SQL Server as well as Postgres, and releases go to npm with provenance through trusted publishing.

It reached 1.0.0 on 22 October and is at 1.2.0 now.

What still bugs me

The adapter is a floor, and you feel it. Anything smarter than CRUD is either a capability, implemented per database, or it happens in JavaScript. Search without the Postgres capability is a substring match and nothing more.

Realtime only crosses processes on Postgres. On the other three you get memoryRealtime() and polling, which is correct and not instant.

Channels are the widget and email, and that will stay. A WhatsApp poller would be a service running next to your app, which is the one thing this project exists to avoid.

And the only production deployment I can name is our own. We run it at devguard, where it was built, so weigh it as that: the authors using their own tool. If you want to poke at it without installing anything, the demo at better-helpdesk.com/demo lets you write in as a customer, answer as the agent and look at the rows in Postgres underneath. The code is on GitHub, and an issue saying what would stop you from using it is the most useful thing you could send.

‘Till next time!

Steps in this article

  1. 01What you mount
  2. 02Who is asking
  3. 03Four databases, one model
  4. 04Realtime without a websocket server
  5. 05Jobs without a queue
  6. 06The widget and the browser
  7. 07Email, and not trusting the relay
  8. 08Shipping it
  9. 09What still bugs me