---
title: "GitHub source"
description: "Load Markdown straight from a GitHub repository."
canonical_url: "https://content.comark.dev/sources/github"
---
# GitHub source

> Load Markdown straight from a GitHub repository.

The `github` source fetches content from a GitHub repository through the GitHub API. It's the recommended way to ship applications whose source-of-truth lives in another repo.

## `github(options)`

Creates a source that reads from a GitHub repository.

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

const content = comarkContent({
  source: github({
    repo: 'comarkdown/comark',
    branch: 'main',
    path: 'docs/content',
  }),
})
```

**Parameters:**

- `options` - A [`GithubSource`](https://content.comark.dev/reference/types/content#githubsource) object

**Returns:** [`Source`](https://content.comark.dev/reference/types/content#source). Pass it to `comarkContent({ source })`.

## Options

`GithubSource` extends unstorage's `github` driver options and adds the source-level fields:

| Option                        | Type         | Default     | Description                                                                                |
| ----------------------------- | ------------ | ----------- | ------------------------------------------------------------------------------------------ |
| [`repo`](#options-repo)       | `string`     | -           | `owner/name` slug of the repository. Required.                                             |
| [`branch`](#options-branch)   | `string`     | `'main'`    | Branch, tag, or commit SHA to read from.                                                   |
| [`path`](#options-path)       | `string`     | `''`        | File or directory inside the repo to use as the content root.                              |
| [`token`](#options-token)     | `string`     | `undefined` | GitHub token. Required for private repos; recommended for public ones to lift rate limits. |
| [`ttl`](#options-ttl)         | `number`     | `600`       | Cache duration for the underlying driver, in seconds.                                      |
| [`prefix`](#options-prefix)   | `string`     | `undefined` | Prepended to each entry's `path` (for example `/blog`).                                    |
| [`exclude`](#options-exclude) | `string[]`   | `undefined` | Picomatch globs of keys to drop before parsing.                                            |
| [`schema`](#options-schema)   | `JsonSchema` | `undefined` | Explicit document `data` schema for types and query columns.                               |

::note
Any other option is forwarded to [`unstorage/drivers/github`](https://unstorage.unjs.io/drivers/github): `apiURL`, `cdnURL`, and so on.
::

Reads from GitHub are network fetches, so the source is flagged [`expensiveReads`](https://content.comark.dev/sources/custom#interface-expensivereads): the raw bodies downloaded during a partial `init()` are kept in the [cache](https://content.comark.dev/advanced/caching) and reused by the first `get()` per page — no second fetch only to parse the body.

### `repo`

The `owner/name` slug of the repository to read from. This is the only required option.

### `branch`

The branch, tag, or commit SHA to read from. Defaults to `main`. Pin it to an immutable commit SHA when you need every instance to render the exact same snapshot.

### `path`

The file or directory inside the repo to use as the content root. The Content instance reads only the entries under this path:

```ts [scoped.ts]
github({
  repo: 'nuxt/nuxt',
  branch: 'main',
  path: 'docs/1.getting-started',
})

await content.list()  // only entries under docs/1.getting-started/**
```

### `token`

A GitHub token. Public repos work without one but share a 60-request/hour rate limit per IP. For anything serious, pass a [personal access token](https://github.com/settings/tokens) (fine-grained `Contents: read` is enough):

```ts [private-repo.ts]
const content = comarkContent({
  source: github({
    repo: 'org/private-docs',
    branch: 'main',
    path: 'content',
    token: process.env.GITHUB_TOKEN,
  }),
})
```

### `ttl`

How long the underlying driver caches responses, in seconds. Defaults to `600`.

### `prefix`

Prepended to every entry's public `path`. Combine it with [`path`](#options-path) to remap the public path space:

```ts [mounted.ts]
github({
  repo: 'org/handbook',
  branch: 'main',
  path: 'content/policies',
  prefix: '/policies',
})

await content.get('/policies/security') // reads content/policies/security.md in org/handbook
```

The prefix does **not** affect [`meta.key`](https://content.comark.dev/guide/files-and-paths#internal-identifiers) (which stays `<instance name>/<file key>`, for example `default/security.md`), only the public `path` returned by [`content.get()`](https://content.comark.dev/reference/content/get) and [`content.list()`](https://content.comark.dev/reference/content/list).

### `exclude`

Picomatch globs matched against each entry's key. Excluded files are dropped **before** parsing, so they never enter the manifest:

```ts [drafts.ts]
github({
  repo: 'org/docs',
  branch: 'main',
  path: 'content',
  exclude: ['drafts/**', '**/*.draft.md'],
})
```

### `schema`

An explicit JSON Schema for the source's `data`. When set, the Content instance reports Markdown, JSON, and YAML frontmatter validation issues during source load and on direct updates, and uses the schema to generate types and query columns.

## Production: serve one commit and follow the branch

A branch moves; an instance reads one source. Rather than telling a running instance about every
push, production serves the content at a **commit** and replaces the instance when the branch
advances. [`content.withRef(sha)`](https://content.comark.dev/reference/content/with-ref) gives you an instance pinned to that
commit, with its own cache namespace. The rest is a small amount of application code.

::steps{level="3"}
### Resolve the commit to serve

Ask GitHub for the latest commit that touched the content directory on the target branch, and cache
the answer in a store every function instance reads, with a short TTL. Filtering by path means a
code-only commit doesn't move the pointer.

```ts [server/utils/pointer.ts]
const refs = createStorage({ driver: yourSharedDriver({ base: 'content:refs', ttl: 60 }) })

export async function resolveContentSha(branch = 'main', dir = 'docs/content', refresh = false) {
  const key = `${branch}:${dir}`
  if (!refresh) {
    const cached = await refs.getItem<string>(key)
    if (cached) return cached
  }
  const [commit] = await $fetch<Array<{ sha: string }>>('https://api.github.com/repos/org/docs/commits', {
    query: { sha: branch, path: dir, per_page: 1 },
    headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
  })
  await refs.setItem(key, commit.sha)
  return commit.sha
}
```

### Namespace the cache by commit

A commit is immutable, so its parsed content can be cached for a long time. Pass a driver whose
base includes a parser version and let `withRef()` add the commit. Bump the version when you change
plugins or parser options that affect cached output; unrelated deployments then keep reusing warm
namespaces.

```ts [server/utils/content.ts]
const base = comarkContent({
  source: github({ repo: 'org/docs', branch: 'main', path: 'docs/content' }),
  cache: { driver: yourSharedDriver({ base: 'content:v3', ttl: 60 * 60 * 24 }) },
})
```

### Swap the instance when the pointer moves

Resolve the pointer on each request. If it differs from the commit the current instance serves, drop
the instance and build a new one. Keep the promise, not the instance, so two requests on a cold
function don't build twice, and don't memoize a failed build.

```ts [server/utils/content.ts]
let headSha: string | undefined
let current: Promise<ReturnType<typeof base.withRef>> | undefined

export async function getContent() {
  const sha = await resolveContentSha()
  if (sha !== headSha) {
    headSha = sha
    const previous = current
    current = undefined
    void previous?.then((instance) => instance.dispose())
  }
  current ??= (async () => {
    const instance = base.withRef(sha)
    await instance.init()
    return instance
  })().catch((error) => {
    current = undefined
    throw error
  })
  return current
}
```

Preview deployments skip the resolution and stay pinned to the commit they were built at.

### Warm the new commit from the webhook

The push webhook verifies its signature, refreshes the pointer, and warms the new namespace so the
first request after the push doesn't pay for the walk. Everything here is idempotent, so a retried
or duplicated delivery repeats work and changes nothing.

```ts [server/api/revalidate.post.ts]
export default defineEventHandler(async (event) => {
  await verifySignature(event, process.env.WEBHOOK_SECRET!)
  const sha = await resolveContentSha('main', 'docs/content', true)
  waitUntil(base.withRef(sha).init({ partial: false }))
  // Then purge the rendered pages that depend on content.
  return { ok: true, sha }
})
```
::

### What this gives you

- **One commit per request.** Listings, navigation, and bodies come from the same instance, so a
push between two calls can't mix versions.
- **No coordination.** Each writer fills its own commit's namespace with deterministic content.
Duplicates are harmless; a delayed job can't overwrite a newer one.
- **Visibility delay equals the pointer TTL**, or zero right after the webhook's forced refresh.
- **Recovery reads at the commit**, never at the moving branch head.
- **Old namespaces expire** by the driver's TTL. To delete one sooner, call
[`clean()`](https://content.comark.dev/reference/content/clean) on that ref's instance.

::warning
A regional cache confines the webhook's forced refresh to its region; other regions pick up the new
pointer when their cached copy expires. For a multi-region deployment, put the pointer in a globally
replicated store.
::

## Refresh a single instance

Without refs, a running instance learns about a push only when you tell it. GitHub sources don't
watch for changes, and the driver caches responses for [`ttl`](#options-ttl) seconds. Call
[`content.refresh()`](https://content.comark.dev/reference/content/refresh) to re-read the origin and reconcile the index:

```ts [webhook.ts]
export default defineEventHandler(async () => {
  await content.refresh()
  return { revalidated: true }
})
```

This updates the instance that handles the webhook. Other running instances keep their index until
they restart, which is why the [production pattern](#production-serve-one-commit-and-follow-the-branch)
replaces instances instead.

## Warm start from a build-time seed

Every cold start walks the repository: one API call to list the tree, then one read per file to collect frontmatter. On a serverless platform, where instances start often, that is the slowest and most rate-limited thing your application does.

Ship a snapshot as a seed instead. [`withSnapshot()`](https://content.comark.dev/sources/snapshot#withsnapshot) keeps GitHub as the authority while cold boots serve from parsed data:

```ts [server/content.ts]
import { comarkContent } from 'comark-content'
import github from 'comark-content/sources/github'
import { withSnapshot } from 'comark-content/sources/snapshot'

export const content = comarkContent({
  source: withSnapshot(
    github({ repo: 'org/docs', branch: 'main', path: 'content' }),
    () => loadSeed('snapshot.json'),
    () => loadSeed('manifest.json')
  ),
  // A cache that outlives the instance is what carries runtime updates forward.
  cache: { driver: runtimeCacheDriver() },
})
```

Write the seed during the build with [`comark-content snapshot`](https://content.comark.dev/reference/cli#comark-content-snapshot), and load it however your platform ships files — see [Nuxt](https://content.comark.dev/deployment/with-a-snapshot#nuxt) and [Next.js](https://content.comark.dev/deployment/with-a-snapshot#nextjs) for the two bundling mechanisms.

GitHub stays reachable throughout, which is the difference from a plain [`snapshot()`](https://content.comark.dev/sources/snapshot) source: `refresh()`, [`watch()`](https://content.comark.dev/reference/content/watch), media bytes and `comark-content snapshot` all still use it.

With [pinned instances](#production-serve-one-commit-and-follow-the-branch), build the seed on a pinned instance too (`content.withRef(sha).snapshot()`). The seed then carries its commit. At a later commit it's never used as the index, but every body whose source text didn't change is taken from it without a parse; only the changed files are read at the new commit. See [which bodies hydrate](https://content.comark.dev/advanced/artifacts-and-hydration#which-bodies-hydrate).

### Which index wins

A seed is a starting point, not the truth. The index and the seed compete by timestamp, and the newest one wins:

| Scenario                                      | Result                                                         |
| --------------------------------------------- | -------------------------------------------------------------- |
| First boot of a deployment                    | The seed serves. GitHub isn't walked.                          |
| A runtime update or removal, then a cold boot | The runtime index wins. The edit survives without a redeploy.  |
| A deployment ships a newer seed               | The seed wins, replacing older runtime state.                  |
| `refresh()`                                   | GitHub wins, and the result is stamped so later boots keep it. |

[`update()`](https://content.comark.dev/reference/content/update), [`remove()`](https://content.comark.dev/reference/content/remove) and [`refresh()`](https://content.comark.dev/reference/content/refresh) re-stamp the persisted index as they write it. Without that, a deployment built *after* the seed an edit was applied to would look newer than the edit and silently revert it.

Ties keep the persisted index, so re-deploying the same content never discards runtime state.

#### The stamps come from two clocks

The seed's `time` is the **build machine's** clock at build time. A runtime edit's `time` is the **host's** clock. The comparison assumes the two agree.

If the build machine runs ahead, a seed built shortly *before* an edit can carry a stamp *after* it, win the comparison, and revert the edit on the next cold boot. If it runs behind, a genuinely newer seed can lose to an older edit.

Cloud runners and hosts are NTP-synced, which keeps the disagreement under a second, so this needs an edit and a build within the same second to matter. It is a real assumption all the same: a self-hosted runner with a wrong clock will produce exactly this. There is no fix inside the library — two machines have no shared monotonic clock — so keep the clocks synced and know where to look if an edit ever "comes back".

::warning
This depends on a cache whose driver outlives the instance — a Redis, KV, or platform runtime cache. With the default in-memory cache, every instance starts from the seed and runtime updates die with the instance that made them.
::


## Sitemap

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