---
title: "contentHub()"
description: "Compose single-source Content instances into one read surface."
canonical_url: "https://content.comark.dev/reference/content-hub"
---
# contentHub()

> Compose single-source Content instances into one read surface.

## `contentHub(instances, options?)`

Composes [`comarkContent()`](https://content.comark.dev/reference/content/comark-content) instances into one read surface. Each
instance keeps its own source, cache policy and plugins — see the [hub concept](https://content.comark.dev/advanced/hub) for
what that means in practice.

**Parameters:**

- `instances`: an array of instances, in **collision-priority order**. See [Ordering](#ordering).
- `options?`: a [`ContentHubOptions`](#options) object.

**Returns:** [`ContentHub<T>`](https://content.comark.dev/reference/types/content#contenthubt), typed against the composed
instances: names autocomplete, and [`from()`](#methods-from) returns the exact instance type.

```ts [content.ts]
import { comarkContent, contentHub } from 'comark-content'
import fs from 'comark-content/sources/fs'

const docs = comarkContent('docs', { source: fs('./content/docs') })
const blog = comarkContent('blog', { source: fs('./content/blog', { prefix: '/blog' }) })

export const hub = contentHub([docs, blog])
```

**Throws** at construction when the array is empty, or when two instances share a name.

## Ordering

Registration order is the tie-break for colliding public paths: the instance listed first owns the
path, on every surface (`get`, `stat`, `list`, `navigation`, the merged manifest and the HTTP `list`
route). One warning is logged per colliding path, whichever surface notices first.

Order matters for nothing else. Reads consult instances as needed, and
[`init()`](#methods-init) runs them in parallel.

## Options

| Option     | Type                                                                             | Default          | Description                                                                                        |
| ---------- | -------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| `basePath` | `string`                                                                         | `'/api/content'` | Path [`handler()`](#methods-handler) is mounted at. Independent of each instance's own `basePath`. |
| `logger`   | [`Logger`](https://content.comark.dev/reference/types/content#logger)` \| false` | `console`        | Where hub diagnostics go (collisions, search coordination). `false` silences them.                 |

## Properties

| Property    | Type                                        | Description                                               |
| ----------- | ------------------------------------------- | --------------------------------------------------------- |
| `instances` | `T`                                         | The composed instances, in registration order.            |
| `names`     | `string[]`                                  | Their names, in registration order.                       |
| `basePath`  | `string`                                    | Where `handler()` is mounted.                             |
| `media`     | [`HubMedia`](#methods-media)` \| undefined` | Media across instances, when any instance has the plugin. |

## Methods

### `from()`

`from(name)` returns a composed instance, with its own generated types and plugin methods. Names
autocomplete; an unknown name throws.

```ts [from.ts]
const post = await hub.from('blog').get('/hello')
post?.data.title // typed from the blog schema
```

### `withRefs()`

`withRefs(refs)` returns a new hub in which each named instance is pinned to its ref through
[`content.withRef()`](https://content.comark.dev/reference/content/with-ref), and the other instances are reused as they are.
Keys are instance names; an unknown name or an empty ref throws. The current hub isn't mutated, and
cross-instance references are wired again on the new one.

```ts [with-refs.ts]
// Serve the docs at a commit; the blog keeps reading its default branch.
const pinned = hub.withRefs({ docs: commitSha })
await pinned.get('/guide')
```

There is no `hub.withRef()`: the map says which instances move, so nothing is implied.

### `init()`

`init(opts?)` initializes every instance in parallel. Takes the same options as
[`content.init()`](https://content.comark.dev/reference/content/init) and forwards them unchanged, so
`init({ partial: false })` warms every body across the hub.

Read methods call it for you.

### `manifest()`

`manifest()` initializes every instance and returns the **merged**
[`Manifest`](https://content.comark.dev/reference/types/content#manifest):

- `sources` lists every composed instance name.
- `items` is the union of their entries, first-registered-wins on collisions.
- `schemas` is keyed by instance name.
- `time` is the newest instance stamp.

```ts [manifest.ts]
const manifest = await hub.manifest()
manifest.sources // ['docs', 'blog']
```

There is no `hub.snapshot()`. A snapshot describes one instance's parsed data; call
[`content.snapshot()`](https://content.comark.dev/reference/content/snapshot) on each, or run
[`comark-content snapshot`](https://content.comark.dev/reference/cli#comark-content-snapshot), which writes one directory per
instance.

### `stat()`

`stat(key)` looks up a single manifest entry across instances, first match wins. Synchronous, so it
only sees instances that are already initialized.

### `get()`

`get(path, opts?)` reads one document across instances. A `<name>/<stem>.<ext>` key routes straight
to that instance without initializing the others; anything else resolves through the merged index.
Options match [`content.get()`](https://content.comark.dev/reference/content/get).

```ts [get.ts]
await hub.get('/hello') // resolved across instances
await hub.get('blog/hello.md') // routed to `blog`, no init
```

Returns `null` when no instance owns the path.

### `list()`

`list(names?)` lists documents across instances, deduplicated by public path. Pass names to narrow;
known names narrow the item `data` type too.

```ts [list.ts]
const all = await hub.list()
const posts = await hub.list(['blog'])
```

### `navigation()`

`navigation(names?)` builds one tree from the same deduplicated listing, so a shadowed path appears
once.

### `search()`

`search(query, opts?)` runs full-text search across every instance carrying the
[`sqliteFullTextSearch`](https://content.comark.dev/plugins/built-in/full-text-search) plugin. `opts` is
[`SearchOptions`](https://content.comark.dev/reference/types/content#searchoptions) plus `instances?: string[]` to narrow.

```ts [search.ts]
const hits = await hub.search('streaming markdown', { limit: 10, instances: ['docs'] })
```

Instances without the plugin are skipped, and a hub with none returns `[]`. Ranking is only
comparable when the instances [share one database](https://content.comark.dev/plugins/built-in/full-text-search#shared-database).

### `query()`

`query(name)` returns the named instance's [`sqlQuery`](https://content.comark.dev/plugins/built-in/sql-query) builder —
identical to `hub.from(name).query()`. Throws when that instance has no `sqlQuery` plugin.

```ts [query.ts]
await hub.query('blog').where('data.draft', '=', false).all()
```

### `handler()`

`handler(request)` answers a web-standard `Request` for every instance at once. Mount it once:

```ts [server/api/content/[...path\\].ts]
export default eventHandler((event) => hub.handler(toWebRequest(event)))
```

### `watch()`

`watch()` starts watching every instance and resolves to one stop function that stops them all.

```ts [watch.ts]
const stop = await hub.watch()
await stop()
```

### `dispose()`

`dispose()` [disposes](https://content.comark.dev/reference/content/dispose) every composed instance. There is no `hub.clean()`;
deleting persisted data is explicit, one instance at a time.

### `media`

`hub.media` is present when at least one composed instance has the [`media`](https://content.comark.dev/plugins/built-in/media) plugin, and `undefined` otherwise — so its absence means "no media plugin", never "no media".

- `media.get(key)` resolves the owning instance by public path or `name/file.ext` key (first registered wins on a collision) and returns that instance's bytes, or `null`.
- `media.list(names?)` concatenates the selected instances' media entries, deduplicated by path.

```ts [media.ts]
const logo = await hub.media?.get('/logo.svg')
const images = await hub.media?.list(['blog'])
```

An instance without the plugin indexes no media, so it never owns a media path; `list()` skips it.

## Handler routes

The first path segment after `basePath` selects the section. A `.json` suffix is accepted on the
section everywhere.

| Path                    | Serves                                                                                                        | Notes                                                                                   |
| ----------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `manifest`              | The merged manifest, as a [`CacheArtifact`](https://content.comark.dev/reference/types/content#cacheartifact) | Initializes every instance                                                              |
| `manifest/<name>`       | One instance's persisted manifest artifact                                                                    | Built and cached by that instance                                                       |
| `navigation`            | [`NavigationItem[]`](https://content.comark.dev/reference/types/content#navigationitem) across instances      |                                                                                         |
| `snapshot/<name>`       | Forwarded to that instance's `snapshot` route                                                                 | `404` without a known name — there is no merged snapshot                                |
| `get/<path>`            | `ContentFile \| null` via [`get()`](#methods-get)                                                             | `?instance=<name>` restricts resolution to that instance; unknown name is `404`         |
| `list/<json-names>`     | [`ContentListFile[]`](https://content.comark.dev/reference/types/content#contentlistfiletdata)                | Omit the names for every instance                                                       |
| `search/<json-payload>` | [`SearchResult[]`](https://content.comark.dev/reference/types/content#searchresult)                           | `{ query, sources?, opts? }`                                                            |
| `query/<json-payload>`  | Forwarded to the instance named by `source`                                                                   | Defaults to the first instance                                                          |
| *custom*                | The first instance whose plugins registered the section                                                       | Via [`addServeHandler`](https://content.comark.dev/reference/content/add-serve-handler) |

Forwarded requests are rewritten to the owning instance's own `basePath`, so instances mounted
elsewhere still answer.

### Errors

Errors match the [instance handler](https://content.comark.dev/reference/content/handler#errors): a JSON body of
`{ error, message }`.

| Status | Code             | When                                                              |
| ------ | ---------------- | ----------------------------------------------------------------- |
| `400`  | `bad_request`    | `list` names are not a JSON-encoded array                         |
| `404`  | `not_found`      | Unknown section, unknown instance name, or `snapshot` without one |
| `500`  | `internal_error` | A route threw                                                     |


## Sitemap

See the full [sitemap](https://content.comark.dev/sitemap.md) for all pages.
