---
title: Self-host the backend
description: Run the c15t consent backend yourself with @c15t/backend, create
  its database schema with the CLI, mount it in Next.js, TanStack Start, Nuxt,
  SvelteKit or another server, and point your app at it.
group: self-host
lastModified: "2026-10-10T16:01:45+01:00"
---
## Before you start

[Inth](https://inth.com) 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

|Package manager|Command|
|:--|:--|
|npm|`npm install @c15t/backend@alpha`|
|pnpm|`pnpm add @c15t/backend@alpha`|
|yarn|`yarn add @c15t/backend@alpha`|
|bun|`bun add @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.

|Database|Driver|
|--|--|
|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:

```ts title="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](/docs/self-host/guides/database-setup)
covers PostgreSQL and MySQL, and [policy configuration](/docs/self-host/guides/policy-packs)
covers rules for other regions.

## Create the database schema

Run the migration with the same configuration the server uses:

```bash
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](/docs/cli/commands/self-host) 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

```ts title="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`](https://github.com/c15t/c15t/tree/v3/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:

```ts title="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()`:

```ts title="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:

```ts title="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](/docs/frameworks) 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](/docs/self-host/api/configuration)
and [HTTP endpoints](/docs/self-host/api/endpoints).
