Skip to content

Client SDK

@timbl/client is a framework-agnostic REST client built on fetch. It infers types from your content.config.ts so frontend code is typed end-to-end without bundling the runtime.

Setup

import { createTimblClient } from "@timbl/client";
import type { InferCMS } from "@timbl/core";
import type cms from "../../content.config";
type CMS = InferCMS<typeof cms>;
export const client = createTimblClient<CMS>({
baseUrl: "http://localhost:3000",
});

The config is imported as a type-only value. The @timbl/core runtime is never bundled into your frontend. The client ships zero runtime dependencies.

Collections

findMany(query)

List entries. query maps to HTTP query parameters.

const posts = await client.collection("posts").findMany({
status: "published",
sort: "-date",
limit: 10,
offset: 0,
q: "hello",
include: ["tags"],
depth: 1,
select: ["title", "slug"],
});
// posts: Post[] (typed from your schema)
// [{ id: "abc123", title: "Hello", slug: "hello", body: "...", bodyHtml: "...", status: "published", tags: [{...}], ... }]

Returns an array of entries with the fields you requested. When include is set, relation fields are replaced with the full related documents.

findBySlug(slug, query)

Get one entry by slug.

const post = await client.collection("posts").findBySlug("hello", {
include: ["tags", "author"],
depth: 2,
});
// post: Post | null
// { id: "abc123", title: "Hello", slug: "hello", body: "...", bodyHtml: "...", tags: [{...}], author: {...} }

Returns null when no entry matches. The runtime throws TimblHttpError with code: "NOT_FOUND" only for server-side errors; a missing slug returns null on the client.

create(data)

Create an entry. Requires a session.

const created = await client.collection("posts").create({
title: "Hello",
slug: "hello",
body: "# Hello",
status: "published",
});
// created: Post (with auto-generated id, createdAt, updatedAt, bodyHtml)
// { id: "abc123", title: "Hello", slug: "hello", body: "# Hello", bodyHtml: "<h1>Hello</h1>", status: "published", createdAt: "...", updatedAt: "..." }

update(id, data, options?)

Update an entry by id. Requires a session. Pass ifMatch for optimistic concurrency.

const updated = await client.collection("posts").update(
"abc123",
{ title: "Updated" },
{ ifMatch: 'W/"..."' },
);

delete(id)

Delete an entry by id. Requires a session.

await client.collection("posts").delete("abc123");

Globals

get()

Read a singleton global.

const settings = await client.global("siteSettings").get();
// settings: SiteSettings (typed from your global schema)
// { siteName: "My Site", siteUrl: "https://example.com" }

set(data, options?)

Write a singleton global. Requires a session.

await client.global("siteSettings").set({ siteName: "My Site" });

Assets

findMany(query)

List assets. Public by default.

const assets = await client.assets.findMany({ limit: 20 });

findById(id)

Get one asset by id.

const asset = await client.assets.findById("asset_123");

upload({ file, filename?, directory?, alt? })

Upload a file. Requires a session. Pass a File object (browser) or a Blob.

const uploaded = await client.assets.upload({
file: fileInput.files[0],
directory: "posts",
filename: "cover.jpg",
alt: "Cover image",
});
// uploaded: StoredFile
// { id: "asset_123", filename: "cover.jpg", originalName: "photo.jpg", mimeType: "image/jpeg", size: 524288, path: "posts/cover.jpg", url: "/uploads/posts/cover.jpg" }

delete(id)

Delete an asset by id. Requires a session.

await client.assets.delete("asset_123");

Auth

session()

Check the current session.

const { authenticated, principal, identity } = await client.auth.session();
// { authenticated: true, principal: { id: "user_123", email: "editor@example.com" }, identity: { provider: "better-auth", subject: "user_123", actorType: "human", ... } }
// or when unauthenticated: { authenticated: false, principal: null, identity: null }

action(action, body)

Invoke a canonical auth action (login, register, logout, or a custom action advertised by the adapter).

await client.auth.action("login", { email: "editor@example.com", password: "..." });

System

health()

Call GET /api/health. Returns process health status.

ready()

Call GET /api/ready. Returns 503 when a dependency (database) is not ready.

openApi()

Fetch the generated OpenAPI document at /openapi.json.

exportContent()

Call GET /api/export. Available only when the runtime is started with ENABLE_CMS_EXPORT=true. Requires an authenticated service principal.

Error handling

Non-2xx responses throw TimblHttpError. Inspect the code property to decide how to react.

import { TimblHttpError } from "@timbl/client";
try {
await client.collection("posts").findBySlug("missing");
} catch (error) {
if (error instanceof TimblHttpError && error.code === "NOT_FOUND") {
// render a 404 page
} else {
throw error;
}
}

The code values align with @timbl/core ErrorCodes. See Error Codes for the full list and HTTP status mapping.

Query parameters

The query object on findMany and findBySlug accepts:

ParamTypePurpose
limitnumberMax entries to return
offsetnumberSkip entries for pagination
sortstringSort field, prefix with - for descending
qstringFull-text search query
statusstringFilter by status field
selectstring[]Project specific fields
includestring[]Expand relation fields
depthnumberRelation expansion depth
localestringRequest a specific locale
fallbackLocalestringFallback locale when the requested one is missing

What the client does not expose

The client deliberately does not expose private runtime modules, Hono types, Drizzle types, or direct adapter handles. If you need those, you are writing a plugin or an adapter, not a frontend. See Plugins and Custom Adapters.

See also