---
title: "Combine content sources"
description: "Serve several bodies of content, each with its own source and settings, through one API."
canonical_url: "https://content.comark.dev/advanced/hub"
---
# Combine content sources

> Serve several bodies of content, each with its own source and settings, through one API.

One Content instance reads one source, and that source can hold as many folders as you like. You don't need this page to put a blog and a docs section in the same `content/` folder.

You need it when two bodies of content can't share one instance:

- **Different sources.** The docs live in a separate GitHub repository that writers own; the blog lives next to the app.
- **Different settings.** The docs should be indexed for full-text search; the marketing pages shouldn't pay for a database.
- **Different schemas.** Authors and posts have different frontmatter, and you want [generated types](https://content.comark.dev/guide/typescript) for each.

A **hub** takes those instances and gives your app one surface: one merged index, one navigation tree, one HTTP handler.

## Compose two instances

Give each instance a name and a `prefix`, so their paths don't compete, then pass them to `contentHub()`:

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

export const docs = comarkContent('docs', {
  source: github({ repo: 'acme/docs', path: 'content', prefix: '/docs' }),
})

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

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

```ts
await hub.get('/docs/getting-started') // content/getting-started.md in acme/docs
await hub.get('/blog/hello') // ./content/blog/hello.md
await hub.navigation() // one tree: /docs/… from GitHub, /blog/… from disk
await hub.list() // every page from both
await hub.list(['blog']) // only the blog
```

The names (`docs`, `blog`) identify each instance in [internal keys](https://content.comark.dev/guide/files-and-paths#internal-identifiers), cache namespaces, and generated types. The prefixes shape the public paths. Both are your choice; the name never appears in a path.

## What a hub is not

A hub is a **read surface**, not a larger Content instance. It doesn't merge the instances or their
configuration:

| Stays per instance                      | Why                                                                                                                    |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Source                                  | The whole point: one source each.                                                                                      |
| Cache (`driver`, `ttl`, `swr`)          | A GitHub instance and a filesystem instance rarely want the same policy.                                               |
| Plugins                                 | `docs` can index for search while `blog` skips the database entirely.                                                  |
| `manifest()`, `snapshot()`, `refresh()` | Producers describe one instance's data. The hub merges the index for reading; it does not produce a combined snapshot. |

Two plugin surfaces work across instances: [`hub.media`](https://content.comark.dev/reference/content-hub#methods-media) picks the owner by path, and [`hub.search()`](#queries-and-search-search-ranks-across-instances) ranks across instances. Both require the corresponding plugin on the instances themselves.

Reach for an instance directly whenever you mean *that* content — `hub.from('blog')` returns it,
fully typed.

## One path namespace

Every instance contributes its public paths to one namespace, so reads don't name an instance:

```ts [reads.ts]
await hub.get('/blog/hello') // owned by whichever instance has that path
await hub.list() // every document, every instance
await hub.list(['blog']) // narrowed to one instance
await hub.navigation(['docs']) // a tree for one instance
```

A [source prefix](https://content.comark.dev/sources/filesystem#options-prefix) is what keeps that namespace tidy. Without
prefixes, `content/blog/hello.md` in the blog instance and `content/hello.md` in the docs repository
would both be `/hello`; see [collisions](#collisions-first-registered-wins) for what happens then.

### Name-prefixed keys skip the search

A full file key (`<name>/<stem>.<ext>`) names its owner, so the hub routes straight to that instance
without consulting any manifest — and therefore without initializing the others:

```ts [fast-path.ts]
await hub.get('blog/hello.md') // routed, no init
await hub.get('/hello') // resolved through the merged index
```

That is the cheapest read a hub can do. It is the same fast path
[`content.get()`](https://content.comark.dev/reference/content/get) uses on a lone instance.

## Collisions: first registered wins

Two instances can hold the same public path. The hub resolves it by **registration order** — the
instance listed first owns the path — and logs one warning per colliding path:

```ts [collision.ts]
const hub = contentHub([docs, blog]) // docs is registered first

// Both hold `/page`. Every surface returns the docs entry.
await hub.get('/page')
hub.stat('/page')
await hub.list() // `/page` appears once
await hub.navigation() // one `/page` node
```

```
[comark-content@hub] path "/page" exists in both "docs" and "blog" — serving "docs". Set a source prefix to separate them.
```

The warning fires once per path, whichever surface notices first, so a plain `get()` reports it
rather than leaving a silently shadowed page. Order is a deliberate tie-break, not a fix: set a
prefix on one of the sources and both pages stay reachable.

## Typed instance access

`hub.from(name)` returns the instance itself, with its own generated types and its plugin methods
intact. Unknown names throw, and names autocomplete from the composed set:

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

hub.from('nope') // Error: contentHub: unknown instance "nope" (known: docs, blog).
hub.names // ['docs', 'blog']
```

## Queries and search

A hub does not invent a query engine. Both surfaces delegate to the plugins on the instances.

### Queries run against one instance

[`sqlQuery`](https://content.comark.dev/plugins/built-in/sql-query) keeps one table per instance, so a query names its
instance:

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

It throws when that instance has no `sqlQuery` plugin. `hub.query('blog')` is exactly
`hub.from('blog').query()`.

### Search ranks across instances

[`sqliteFullTextSearch`](https://content.comark.dev/plugins/built-in/full-text-search) writes into a single `__fts_search`
table, so one query can rank every instance together:

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

For that to rank correctly, every instance must share **one** database. Pass the same
`sqlite()` object to each:

::warning{to="https://content.comark.dev/plugins/built-in/full-text-search#shared-database"}
Give each instance its own database and the hub has to query each index separately and concatenate.
BM25 scores are computed per index, so they are not comparable and the merged order is only
approximate. The hub logs a warning when it detects this — see shared database.
::

Instances without the search plugin are skipped, and a hub whose instances all lack it returns `[]`.

## Pin instances to refs

A hub composes instances; [`withRef()`](https://content.comark.dev/reference/content/with-ref) pins one. `hub.withRefs()` does
both in one step: it returns a new hub whose named instances are pinned and whose others are reused.

```ts [pinned-hub.ts]
const hub = contentHub([docs, blog])

// One request serves the docs at the commit the deployment resolved.
const current = hub.withRefs({ docs: sha })
await current.navigation() // docs at `sha`, blog at its default branch
```

Two pinned instances at the same commit still get distinct cache namespaces and plugin tables,
because the scope is derived from the instance name and the ref together. See the
[GitHub source](https://content.comark.dev/sources/github#production-serve-one-commit-and-follow-the-branch) for the
production pattern this supports.

## References wire themselves

With the [`references`](https://content.comark.dev/plugins/built-in/references) plugin installed, composing instances is what
lets `reference('authors')` resolve: the hub introduces every instance to its siblings.

```ts [references.ts]
const authors = comarkContent('authors', { source: fs('./content/authors', { schema: authorSchema }), plugins: [references()] })
const posts = comarkContent('posts', { source: fs('./content/posts', { schema: postSchema }), plugins: [references()] })

// Wiring happens here: `posts` can now see `authors`.
export const content = contentHub([authors, posts])

const list = await posts.list({ populate: ['author'] })
```

Explicit wiring wins: pass `references({ resolve })` yourself and the hub leaves it alone.

## One handler, one watcher

The application-facing surface is a single endpoint and a single watcher:

```ts [server.ts]
export default eventHandler((event) => hub.handler(toWebRequest(event)))

const stop = await hub.watch() // watches every instance
await stop() // stops them all
```

The handler serves the merged manifest and navigation, routes `get` and `list` across instances, and
forwards per-instance sections (`snapshot/<name>`, `query`, plugin sections) to their owner. A
[client bound to a name](https://content.comark.dev/reference/client/create-content-client#name) reads inside that instance
instead, so a colliding path comes back from the instance the client asked for. See the
[`contentHub()` reference](https://content.comark.dev/reference/content-hub#handler-routes) for the full route table.

::note
Each instance keeps its own `basePath`. The hub rewrites forwarded requests to the owning instance's
base, so mounting them differently is fine.
::


## Sitemap

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