Add a support inbox to your app
Tendkit targets Next.js (App Router), Tailwind 4, shadcn/ui, TanStack Query and Drizzle. Other stacks work by editing the copied code, which is yours.
1. Install
Add the core, a store for your database, the screens, and one adapter per channel. Use support-d1 instead of support-postgres for SQLite or Cloudflare D1.
npx shadcn@latest add \
https://tendkit.softwaresavants.dev/r/support.json \
https://tendkit.softwaresavants.dev/r/support-postgres.json \
https://tendkit.softwaresavants.dev/r/support-ui.json \
https://tendkit.softwaresavants.dev/r/channel-resend.jsonIn a monorepo, run it with --cwd packages/api for the server items and --cwd apps/web for support-ui, then fix the few imports that cross packages.
2. Add the tables
Export the support schema from your Drizzle schema and generate a migration. The copied 0000_init.sql is the reference. For D1 there are no transactions: the store removes what it created if a later write fails.
export * from "@/lib/support/postgres/schema";3. Create the service
Build it once per request with your database, file storage, channels and directory.
import { createSupport } from "@/lib/support/service";
import { createPostgresStore } from "@/lib/support/postgres/store";
import { resendChannel } from "@/lib/support/channels/resend";
export const support = () =>
createSupport({
store: createPostgresStore(db),
storage, // put / get / signed url: S3, R2, Supabase Storage…
channels: [
resendChannel({
id: "email", // stored on every ticket: never change it
apiKey: process.env.RESEND_API_KEY!, // Full access: reading received mail needs it
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
address: "support@acme.com",
replyAddress: "support@in.acme.com",
}),
],
directory, // step 4
notify: async (n) => { /* email your team: n.type, n.ticket */ },
acknowledgement: () => ({ text: "Thanks, we've received your message and will reply to this email." }),
fromName: "Acme Support",
portalChannelId: "email",
ignoreFrom: ["noreply@acme.com"], // your app's own alerts to support@
});4. Tell it who people are
The directory is the only part that knows your product. identify matches a sender to a user and account; the rest power search, linking and assignment.
export const directory: SupportDirectory = {
async identify({ kind, value }) {
if (kind !== "email") return null;
const user = await db.query.users.findFirst({ where: eq(users.email, value) });
return user ? { customerId: user.accountId, userId: user.id, name: user.name } : null;
},
searchCustomers: (q) => db.select({ id: accounts.id, name: accounts.name }).from(accounts).where(ilike(accounts.name, `%${q}%`)).limit(10),
getCustomers: (ids) => db.select({ id: accounts.id, name: accounts.name }).from(accounts).where(inArray(accounts.id, ids)),
listAgents: () => db.select({ id: users.id, name: users.name }).from(users).where(eq(users.role, "admin")),
};5. Receive webhooks and run the schedule
Route provider webhooks to handleWebhook. It verifies the signature, ingests the events and answers the provider.
export async function POST(request: Request, { params }: { params: Promise<{ channel: string }> }) {
const { channel } = await params;
return support().handleWebhook(channel, request);
}Call support().runScheduled() every few minutes from a cron. It polls mailboxes and renews subscriptions for the channels that need it, and does nothing for push-only channels.
6. Mount the screens
Expose the service through your own API layer, behind your own auth, then implement SupportClient with it and wrap the screens in SupportProvider. Pass customerPanel to show your own data next to each ticket.
<SupportProvider
client={supportClient}
viewerId={user.id}
inboxHref="/admin/support"
ticketHref={(id) => `/admin/support/${id}`}
customerPanel={(detail) => <AccountPanel accountId={detail.customer?.id} />}
>
<SupportInbox />
</SupportProvider>Customers get SupportRequests and SupportRequest inside SupportPortalProvider, backed by support().portal. You decide who sees what: pass a userId to show someone only the requests they opened.
Taking upstream fixes
Copied code doesn't update itself. Run npx shadcn@latest add <item> --diff to see what changed and apply what you want. Keep your own changes in the directory, notifier and UI wrappers so the engine files stay easy to diff.