Install in an existing app
Add Better Helpdesk to the Next.js app you already run, with its own schema in your database, one route handler, the agent UI and the widget.
This guide adds Better Helpdesk to a Next.js app that already has users and a session. You need Node.js 22.19 or newer, React 19, Next.js 15 or newer, and a database: PostgreSQL 14, MySQL 8.4 or SQL Server 2022 or newer, or SQLite. The steps below use Postgres; Choose and connect a database has the driver, the connection and the migration URL for the other three. To try the package in a new app first, follow the Quickstart.
Install the package
npm install better-helpdesk pg
npm install --save-dev @types/pgpg is the Postgres driver; on another database, install its driver instead, as Choose and connect a database lists. React and React DOM, the other peer dependencies, are already in your app.
Create the schema
Point the migration command at the database your app uses:
HELPDESK_DATABASE_URL=postgres://user:password@db.example.com:5432/app npx better-helpdesk-migrateIt prints the migrations it applied, such as helpdesk: applied 0000_baseline, 0001_tags_and_counter, and helpdesk: up to date when there are none left to run. Everything the helpdesk stores lives in a helpdesk schema next to your own tables, and the command touches nothing outside it. It also reads APP_DATABASE_URL when HELPDESK_DATABASE_URL is not set. Go to production shows where the command goes in a release.
Give your app the same connection string in HELPDESK_DATABASE_URL.
Configure Next.js
Wrap your Next.js config in withHelpdesk, which lets Next.js compile the package. The widget and the agent UI request every path with a trailing slash, and by default Next.js answers a slashed path with a redirect to the bare one. Choose one of two ways to stop that.
import { withHelpdesk } from 'better-helpdesk/next';
export default withHelpdesk({
// ...your existing options
trailingSlash: true,
});This is what the quickstart uses. It applies to your whole app, not only to the helpdesk: every URL of your own moves to its slashed form, and /pricing now answers with a 308 redirect to /pricing/. If your app already uses trailing slashes, this costs nothing. If it does not, links, canonical URLs and anything that calls your API without the slash take a redirect first.
Connect your sign-in
The helpdesk asks your app who is calling, through a function called identify that reads your session. Write it in lib/identify.ts as Connect your sign-in describes, for your own session, Better Auth, Auth.js, Clerk or Supabase Auth, then come back here.
Build the helpdesk
Create lib/helpdesk.ts:
import { buildHelpdesk, postgresAdapter } from 'better-helpdesk';
import pg from 'pg';
import { identify } from '@/lib/identify';
// next dev re-evaluates this module on every edit; one pool per edit would
// use up the database's connections.
const cache = globalThis as typeof globalThis & { helpdeskPool?: pg.Pool };
if (!cache.helpdeskPool) {
cache.helpdeskPool = new pg.Pool({
connectionString: process.env.HELPDESK_DATABASE_URL,
});
// A connection the server ends would otherwise be an uncaught error that
// stops the process. Checked-out connections need a listener of their own.
cache.helpdeskPool.on('connect', client =>
client.on('error', error =>
console.error('[helpdesk] database connection lost', error)
)
);
cache.helpdeskPool.on('error', () => {});
}
const pool = cache.helpdeskPool;
export const helpdesk = buildHelpdesk({
db: postgresAdapter({ pool }),
referencePrefix: 'ACME',
adminUrl: process.env.HELPDESK_ADMIN_URL ?? 'http://localhost:3000/helpdesk/',
inboxes: {
support: { public: true },
},
identify,
});The pool is kept on globalThis because next dev loads the module again after every edit, and each new pool would open connections of its own until the database refuses more. The error listeners matter in production: when the database ends a connection, during a restart or a failover, pg emits an error, and Node.js stops the process on an error nobody listens for. If your app already has a pg.Pool, pass that one instead.
referencePrefix names your conversations: ACME gives ACME-1000, ACME-1001 and so on. The inbox support is public, so visitors may write to it without signing in; leave public out to accept only signed-in customers. Inboxes lists what else an inbox takes.
adminUrl is where your team opens the agent UI, and it must be exactly that URL. The handler accepts changes only from the origin of adminUrl (and, on widget routes, from an inbox's allowedOrigins). If your team opens the app on another port or host than the one adminUrl names, every reply and every message from the widget is refused with a 403.
Mount the handler
Create 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 };Export all six methods. The agent UI saves its settings with PUT, deletes with DELETE and edits with PATCH, and a method you leave out answers 405 before the helpdesk sees the request. To mount it somewhere else, set basePath to match.
The handler is a plain function from Request to Response, so it mounts in any framework that speaks web Request. examples/hono mounts it in a Hono app and examples/sveltekit in SvelteKit; each README says how the React agent UI fits next to it.
Render the agent UI
Create app/helpdesk/[[...slug]]/page.tsx:
import { HelpdeskAdmin } from 'better-helpdesk/admin';
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';
import { identify } from '@/lib/identify';
export default async function Page() {
const request = new Request('http://localhost/', { headers: await headers() });
const identity = await identify(request);
if (!identity?.isAgent) notFound();
return <HelpdeskAdmin api="/api/helpdesk" basePath="/helpdesk" locale="en" />;
}The handler already refuses every agent route to anyone identify does not call an agent, so the page holds nothing a customer could use. Checking here as well keeps the agent UI's shell out of their sight, and it asks the same identify, so there is one definition of who is an agent. The placeholder URL is fine as long as your identify reads only the request's headers, as the ones in Connect your sign-in do.
Add the widget
Render the widget in your root layout, after the page:
import { HelpdeskWidget } from 'better-helpdesk/widget';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<HelpdeskWidget inbox="support" locale="en" />
</body>
</html>
);
}Keep the rest of your layout as it is. Add the widget covers the script tag, other origins and the widget's events.
Check it
Start your app and open it on the origin adminUrl names. Send a message from the widget, then open /helpdesk/ signed in as someone your identify makes an agent. The conversation is in the inbox. Answer it, and the answer appears in the widget.
Before your first deploy, work through Go to production. Customers hear about replies by email once you send email, and that email waits for jobs to run.