Skip to main content

Backend

Self-host the backend

Before you start

Inth runs this backend for you. Self-host when you need to operate the service and its database yourself. You then own provisioning, migrations, backups, policy configuration, signing secrets, availability and upgrades.

Your c15t app talks to a self-hosted backend exactly as it talks to Inth, through hosted mode. Only the backend URL changes.

Install the backend

npm install @c15t/backend@alpha

Also install the driver for your database. The backend declares each driver as an optional peer dependency pinned to one version; install that version.

DatabaseDriver
PostgreSQL@effect/sql-pg
MySQL@effect/sql-mysql2
SQLite@effect/sql-sqlite-node

Configure it

Create c15t-backend.config.ts in the app that will serve the backend:

c15t-backend.config.ts
import { defineConfig, policyRulePresets } from '@c15t/backend';

export default defineConfig({
	database: { dialect: 'sqlite', filename: './consent.db' },
	trustedOrigins: ['localhost'],
	manifest: { policyRules: [policyRulePresets.europeOptIn()] },
});

This is a local development configuration. trustedOrigins lists the sites whose browsers may call the backend; add your production host before you deploy. Keep the SQLite file on persistent storage, because a temporary filesystem loses consent history. Database setup covers PostgreSQL and MySQL, and policy configuration covers rules for other regions.

Create the database schema

Run the migration with the same configuration the server uses:

npx @c15t/cli@alpha self-host migrate --config ./c15t-backend.config.ts --plan
npx @c15t/cli@alpha self-host migrate --config ./c15t-backend.config.ts --apply

Review the plan before you apply it, and back up an existing database first. Self-hosted migrations explains the report.

Mount the request handler

c15tInstance(options).handler takes a web Request and returns a Response. Mount it on a catch-all route and route GET, POST, PUT, PATCH and OPTIONS to it. PUT publishes legal-document releases; a route that exports only POST leaves the manifest, /init and subject reads unreachable.

Create one instance per server process and reuse it, so requests share its database connection pool. Set basePath to the path prefix of the requests the handler receives, so /api/c15t/init routes to /init.

Next.js App Router

app/api/c15t/[[...path]]/route.ts
import { c15tInstance } from '@c15t/backend';

import config from '../../../../c15t-backend.config';

export const runtime = 'nodejs';
const backend = c15tInstance({ ...config, basePath: '/api/c15t' });

export const GET = (request: Request) => backend.handler(request);
export const POST = GET;
export const PUT = GET;
export const PATCH = GET;
export const OPTIONS = GET;

This route needs a Node.js server. A Next.js static export cannot run it; deploy the backend separately and give the app its absolute URL.

examples/self-host runs this route with an in-process PGlite database in development and PostgreSQL from DATABASE_URL when deployed. Its banner calls the backend at the same-origin path /api/c15t.

TanStack Start

The TanStack Start example mounts the backend at /api/self-host as a server route. Its getInstance() creates one c15tInstance() on first use and reuses it:

src/routes/api/self-host/$.ts
const handle = async function handle({ request }: { request: Request }) {
	const instance = await getInstance();
	return instance.handler(request);
};

export const Route = createFileRoute('/api/self-host/$')({
	server: {
		handlers: {
			DELETE: handle,
			GET: handle,
			OPTIONS: handle,
			PATCH: handle,
			POST: handle,
			PUT: handle,
		},
	},
});

Nuxt

The Nuxt example mounts the backend at /api/self-host as a Nitro catch-all route, converting the h3 event with toWebRequest():

server/api/self-host/[...all].ts
export default defineEventHandler(async (event) => {
	const instance = await getInstance();
	return instance.handler(toWebRequest(event));
});

SvelteKit

The SvelteKit example creates its c15tInstance() as backend in src/lib/server/c15t-backend.ts. The route at /api/self-host imports that module dynamically on the first request and forwards each request to it:

src/routes/api/self-host/[...all]/+server.ts
import type { RequestHandler } from './$types';

// Load the backend on first request, in its own chunk. SvelteKit 3 builds
// with Rolldown, which can otherwise move modules the backend shares with
// lazily loaded chunks into this route's chunk and export them from it, and
// SvelteKit rejects a route that exports anything besides its handlers.
const loadBackend = () => import('#lib/server/c15t-backend.js');

const handleRequest: RequestHandler = async ({ request }) => {
	const { backend } = await loadBackend();
	return backend.handler(request);
};

export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const PATCH = handleRequest;
export const OPTIONS = handleRequest;

Keep the import dynamic on SvelteKit 3. A static import lets the build place modules the backend shares with its lazily loaded chunks in the route's own chunk, and SvelteKit then fails the build with Invalid export for the route.

Another server

Pass the incoming web Request to handler and return its Response unchanged, keeping status, body and headers such as CORS and cache headers. If your framework has its own request type, convert it with the framework's web request adapter. If a parent router already strips the prefix, leave basePath unset rather than stripping it twice.

Tests and one-off scripts should call instance.dispose() when they finish to close the connection pool. A long-running server does not need to.

Point your app at the backend

Follow your framework quickstart and use your backend's URL, such as https://app.example.com/api/c15t, where it asks for the Inth URL. Server-side resolution needs an absolute URL. The browser only needs the HTTP endpoint; keep the config file and database credentials on the server.

The frontend does not have to run the backend. A static site or an edge-rendered app can call a backend deployed elsewhere, as long as its origin is in trustedOrigins.

Check the deployment

  1. Request /status and /manifest through the public mount point, for example curl -i https://app.example.com/api/c15t/status. /status returns 200 once the database answers and the schema exists. The manifest lists your policy rules.
  2. Load the app from a trusted origin in a private window. The banner appears for a visitor your policy asks to choose.
  3. Accept, then check DevTools Network for a POST to /subjects that returns a success status.
  4. Reload. The banner stays closed and your choice is still applied.
  5. For a cross-origin deployment, check that /init goes out without an OPTIONS preflight and the consent save's OPTIONS preflight succeeds. An /init preflight means something added a custom request header, such as the hosted transport's headers option.

Neither /status nor /manifest writes to the database, so only steps 3 and 4 prove that consent writes and reads work. Continue with configuration and HTTP endpoints.