Skip to content

Custom Adapters

timbl ships with defaults for every infrastructure seam: SQLite with Drizzle (database), Better Auth (auth), local storage (storage), and Hono (HTTP). Each is replaceable through a documented contract. This page covers the four adapter interfaces and how to implement a custom one.

When to write a custom adapter

Write one when the default does not fit. Common reasons:

  • You want Postgres or MySQL instead of SQLite. Implement DatabaseAdapter.
  • You want Clerk, Auth.js, or your own auth system instead of Better Auth. Implement AuthAdapter.
  • You want S3, R2, or a CDN instead of local disk. Implement StorageAdapter.
  • You want Express or a custom server instead of Hono. Implement HttpAdapter.

If the default works, do not write a custom adapter. The defaults are tested and maintained. Custom adapters are your responsibility to keep working across upgrades.

DatabaseAdapter

The database adapter handles persistence. The interface extends DatabaseSession, which means it implements the read and write methods the registry calls: find, findOne, findById, findBySlug, create, update, delete, plus optional search, transaction, and close.

import type { DatabaseAdapter } from "@timbl/core";
export const createMyDatabaseAdapter = (options): DatabaseAdapter => {
return {
find(collection, query) { /* return array of records */ },
findOne(collection, query) { /* return one record or null */ },
findById(collection, id, input) { /* return one record or null */ },
findBySlug(collection, slug, input) { /* return one record or null */ },
create(collection, input) { /* insert and return the record */ },
update(collection, input) { /* update and return the record */ },
delete(collection, input) { /* delete and return the record */ },
search(collection, query, input) { /* optional: full-text search */ },
async transaction(run) { /* optional: wrap in a transaction */ },
close() { /* optional: cleanup on shutdown */ },
};
};

Every method receives a normalized query shape from the runtime. The runtime validates your implementation against DatabaseAdapterMethodSchemas at startup, so a missing or mis-typed method fails fast instead of producing silent runtime errors.

The contract is stable when used through the documented factory function. The raw DatabaseAdapter type from @timbl/core is advanced because it exposes the full session shape.

AuthAdapter

The auth adapter resolves the current principal from a request, provides identity and serialization, and optionally handles auth actions and provider requests.

import { defineAuthAdapter, type AuthAdapter } from "@timbl/core";
export const createMyAuthAdapter = (): AuthAdapter => {
return {
async getPrincipal(context) { /* return the principal or null */ },
async requirePrincipal(context) { /* return the principal or throw */ },
getIdentity(principal) { /* return an AuthIdentity */ },
serializePrincipal(principal, audience) { /* optional: shape for API output */ },
getCapabilities() { /* optional: return AuthCapabilities */ },
async invoke(action, input, context) { /* optional: handle auth actions */ },
async handleRequest(request) { /* optional: handle provider passthrough */ },
};
};

getPrincipal and requirePrincipal are required. The runtime calls getPrincipal on every request to resolve the current user, and requirePrincipal when a route needs a session. The rest are optional.

See the Authentication guide for how auth actions, capabilities, and provider passthrough routes work, and the Better Auth adapter for a full reference implementation.

StorageAdapter

The storage adapter handles file uploads and asset URLs. The interface is small: upload, delete, and url.

import type { StorageAdapter, StorageUploadInput, StoredFile } from "@timbl/core";
export const createS3StorageAdapter = (options): StorageAdapter => {
return {
async upload(input: StorageUploadInput): Promise<StoredFile> {
// input.file: File
// input.directory?: string
// input.filename?: string
// Return { filename, originalName, mimeType, size, path, url }
},
async delete(path: string): Promise<void> {
// Delete the file at path
},
url(path: string): string {
// Return the public URL for a stored file
},
};
};

StoredFile has six required fields: filename, originalName, mimeType, size, path, and url. The runtime validates your implementation against StorageAdapterMethodSchemas at startup.

The default storage adapter writes to a local directory. See Storage and Uploads for the default behavior and how to override it.

HttpAdapter

The HTTP adapter registers routes and starts the server. The interface has two methods: register and listen.

import type { HttpAdapter } from "@timbl/core";
export const createMyHttpAdapter = (): HttpAdapter => {
return {
register(routes) {
// routes: RouteDefinition[]
// Wire each route into your HTTP framework of choice
},
async listen() {
// Start the server
},
app: undefined, // optional: the underlying app instance
};
};

register receives the full list of route definitions (core routes, plugin routes, and auth routes) and is called once at startup. listen starts the server. The app property is optional and exposes the underlying framework instance to the unsafe namespace.

The default HTTP adapter wraps Hono. You rarely need to replace it. If you do, your adapter must accept the same RouteDefinition shape the runtime produces, including method, path, auth, and handler.

Wiring a custom adapter

Pass adapters into defineCMS under the adapters key, or into createCmsRuntime directly.

import { defineCMS } from "timbl";
import { createMyDatabaseAdapter } from "./my-db-adapter";
export default defineCMS({
collections: [/* ... */],
adapters: {
database: createMyDatabaseAdapter({ url: "..." }),
},
});

Only override the adapters you need to replace. The defaults fill in the rest.

See also