Client & handler
The Comark Content instance can run on a server, at the edge, or entirely in the browser.
Runtimes
Comark is plain TypeScript with no Node-only core, so an instance runs anywhere JavaScript does. There are three common shapes:
| Runtime | What runs there | How you read it |
|---|---|---|
| Server / edge | A full Content instance (source, cache, plugins) | Directly in-process, or exposed via content.handler() |
| Browser (client) | A thin HTTP client, no parser or content | createContentClient() calls the server's handler |
| Browser (standalone) | The whole Content, client-side | Hydrate a snapshot with a sqlite-wasm database, no server |
The rest of this page covers the common case: a server handler with a browser client.
One contract, two halves
The most common setup runs the Content instance on a server and reads it from the browser over HTTP:
content.handler(request)exposes the Content instance server-side. It's a single web-standard function —Requestin,Responseout — that dispatches the built-in sections (get,list,navigation) and any custom serve handlers. Because it's justRequest/Response, it drops into Nuxt, Next.js, SvelteKit, Nitro, Hono, or any web-standard server.createContentClient()consumes it from the browser with the same read API: same method names, same return shapes (get(),list(),navigation()).
The parser and your content stay on the server. Only the data crosses the wire.
The client and handler meet at one URL: the handler strips the Content instance's basePath (default /api/content) from the request pathname, and the client issues requests against its own basePath (same default). Keep the two in sync and everything else follows.
Custom plugins
A custom capability is always a pair: a Content plugin that registers a serve handler, and a client plugin that calls it. The two line up one to one, so every section you add on the server becomes a typed method on the browser client:
- Server with
defineContentPlugin→ctx.addServeHandler(name, handler), passed tocomarkContent({ plugins }). - Client with
defineContentClientPlugin→ a method that fetchesname, passed tocreateContentClient({ plugins }).
import { comarkContent, defineContentPlugin } from 'comark-content'
import fs from 'comark-content/sources/fs'
// Register a custom `search-sections` section on the Content instance.
const searchSectionsPlugin = defineContentPlugin(() => ({
setup(ctx) {
ctx.addServeHandler('search-sections', async () => Response.json(await buildSearchSections(content)))
},
}))
export const content = comarkContent({
source: fs('./content'),
plugins: [searchSectionsPlugin()],
})import { createContentClient, defineContentClientPlugin } from 'comark-content/client'
import { joinURL } from 'ufo'
// Call the matching section, fully typed.
const searchSectionsClient = defineContentClientPlugin(() => ({
setup: ({ options }) => ({
searchSections: () => options.fetch(joinURL(options.baseURL, options.basePath, 'search-sections')),
}),
}))
const content = createContentClient({ plugins: [searchSectionsClient()] })
await content.searchSections()The handler name on the server ('search-sections') and the method on the client (searchSections()) are the two halves of one contract. Reach for this instead of inventing bespoke routes: the built-in query and search plugins wire themselves up exactly this way.
baseURL, basePath, fetch, plugins) and method, see the createContentClient reference.