Publish your docs to GitHub Pages
Take a working scribekit docs site live on GitHub Pages as a static export, with a workflow that redeploys on every push.

By the end of this tutorial your docs site is live at a GitHub Pages URL and redeploys itself on every push to your default branch. You patch one config file, add four files, wire two of them into your page route, and push. The workflow turns Pages on by itself the first time it runs.
Prerequisites. A working scribekit docs site that renders locally - see
Build a docs site from scratch - and a GitHub repository with a remote
set up. Node 20.9 or newer (Next 16's floor; the workflow installs 22), and a committed
package-lock.json (the workflow uses npm ci).
A scribekit docs site is already static-export-ready. The Docs class reads the filesystem at
build time, pages render as server components, search is client-side, and every dynamic route
already ships generateStaticParams and dynamicParams = false. So this is almost all config,
with two real trade-offs called out where they bite.
The /scribekit-docs-github-pages Claude Code skill does every step below for you, and it ships in
the package - see scribekit-docs-github-pages. This tutorial is
the manual path: take it if you are not using Claude Code, or if you want to know exactly what the
skill changes before you let it.
1. Turn on static export
Merge three keys into your existing Next config. Preserve everything already in the object.
In next.config.mjs:
// The deploy workflow sets NEXT_PUBLIC_BASE_PATH from GitHub's `configure-pages` `base_path`
// output: empty for a custom domain or user/org site, "/<repo>" for a project site. Unset (a
// local build) means root-served.
const base = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
trailingSlash: true,
images: { unoptimized: true },
...(base ? { basePath: base, assetPrefix: base } : {}),
};
export default nextConfig;
output: "export" is what makes next build emit a static out/ directory instead of a server
bundle. images: { unoptimized: true } keeps a local build from failing if anything in your app
uses next/image. The base spread is what makes the site work under a project subpath: your app
owns the base path, reading one environment variable that both this file and your hero <img>
(step 4) resolve against.
trailingSlash has to agree with your Docs/Blog instance, and the two defaults do not
match. Next's own trailingSlash defaults to false; scribekit's defaults to true. So the line
above is not decoration - without it, the export writes docs/<slug>.html while every URL scribekit
builds ends in a slash, and the two disagree.
The flag decides what the export writes. With true you get docs/<slug>/index.html, so Pages
serves /docs/<slug>/ and redirects /docs/<slug> to it - both forms resolve. With false you get
docs/<slug>.html, so /docs/<slug> is served and /docs/<slug>/ 404s.
To use the bare form instead, turn both off together: trailingSlash: false here, and
trailingSlash: false on your Docs/Blog instance. A mismatch is silent and site-wide - every
canonical, hreflang, sitemap entry, and nav link is built by one helper, so the whole surface points
at the form your host does not serve. Nothing throws; you just publish a sitemap of 404s.
One more thing not to do:
- Do not hardcode a literal base path. Derive it from
NEXT_PUBLIC_BASE_PATH, as above. A hardcodedbasePath: "/my-repo"breaks local builds and every other host you deploy to.
2. Add public/.nojekyll
Create an empty file at public/.nojekyll:
touch public/.nojekyll
It stops the exported _next/ folder from ever being treated as Jekyll source. The artifact
deploy flow does not run Jekyll anyway, so this is belt and braces - it costs nothing and removes
the doubt. A touch out/.nojekyll step in the workflow does the same job; either is enough, and
doing both is harmless.
3. Add the deploy workflow
This file goes at the git repository root, not your Next app directory. Workflows only run
from the repo root's .github/.
Create .github/workflows/deploy.yml:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
# Least-privilege token: only what deploy-pages needs.
permissions:
contents: read
pages: write
id-token: write
# One deploy at a time; let an in-flight run finish rather than cancelling it.
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Set up Node
uses: actions/setup-node@v7
with:
# Next.js 16 requires Node >= 20.9; 22 is the current LTS.
node-version: 22
cache: npm
# Outputs `base_path` ("/<repo>" for a project site, empty for root hosting), and
# `enablement: true` turns Pages on for you on the first run.
- name: Configure Pages
id: pages
uses: actions/configure-pages@v6
with:
enablement: true
- name: Install dependencies
run: npm ci
- name: Build static export
env:
NEXT_PUBLIC_BASE_PATH: ${{ steps.pages.outputs.base_path }}
run: npx --no-install next build
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: ./out
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
Adjust four things to your repo:
- Default branch. The trigger says
branches: [main]. If yours is notmain, change it. - App in a subdirectory. If your Next app lives below the repo root (say
web/), add adefaults: { run: { working-directory: web } }block to thebuildjob, change the uploadpath:to./web/out, and addcache-dependency-path: web/package-lock.jsonundersetup-node'swith:. - No lockfile. Both
npm ciandcache: npmneed a committedpackage-lock.json. If you have none, commit one, or drop thecache: npmline and changenpm citonpm install. - TypeScript app. Your app's own
package.jsonmust listtypescript,@types/node,@types/react, and@types/react-dom. The workflow runsnpx --no-install next build, so it cannot install them for you, and a repo-rootpackage.jsondoes not count for an app in a subdirectory. See the troubleshooting note in step 7.
configure-pages@v6 does two things here. It emits steps.pages.outputs.base_path - /<repo> for
a project site, empty for root hosting - which the build step passes in as NEXT_PUBLIC_BASE_PATH,
the variable step 1's config reads. And enablement: true turns Pages on for the repository on the
first run, so you do not have to.
Note what is deliberately absent: static_site_generator: next. That mode lets the action
inject basePath itself, which would leave your app unable to read the same value - and the hero
<img> in step 4 needs it.
4. Make the hero and in-body links base-path aware
Skip this step only if you are certain you will never host under a subpath. Two things scribekit
renders are raw HTML that no linkComponent touches, and both 404 on a project site unless you
override them: the hero image from image: front-matter (a plain <img src="/assets/...">, which
next/image will not prefix once images.unoptimized is set) and prose links inside your MDX
bodies (plain <a href="/...">).
Add app/docs/_docs-image.tsx:
import type { ImgHTMLAttributes } from "react";
const BASE = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
/** Renders the hero image, prefixing a root-relative src with the deployment base path. */
export function BaseImg({ src = "", ...props }: ImgHTMLAttributes<HTMLImageElement>) {
const resolved = typeof src === "string" && src.startsWith("/") ? `${BASE}${src}` : src;
return <img src={resolved} {...props} />;
}
And app/docs/_docs-links.tsx:
import Link from "next/link";
import type { AnchorHTMLAttributes } from "react";
/** Internal (`/`-rooted) links go through next/link (base-path aware); others stay a plain <a>. */
export function BodyLink({ href = "", children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
if (href.startsWith("/")) {
return (
<Link href={href} {...props}>
{children}
</Link>
);
}
return (
<a href={href} {...props}>
{children}
</a>
);
}
Then wire both into your existing app/docs/[slug]/page.tsx:
import { BaseImg } from "../_docs-image";
import { BodyLink } from "../_docs-links";
// ...
return <DocsPage docs={docs} slug={slug} linkComponent={Link} imgComponent={BaseImg} components={{ a: BodyLink }} />;
BaseImg reads the same NEXT_PUBLIC_BASE_PATH your next.config.mjs does, which is the whole
reason step 3's workflow passes the value to the app instead of letting configure-pages inject it.
5. Set siteUrl to the Pages origin
Your _docs.ts still carries whatever siteUrl you developed against. Leave it and you ship
canonical, sitemap, and OpenGraph URLs pointing at a site that is not yours.
Which URL depends on the repository:
| Repository | Live at | siteUrl |
|---|---|---|
Named <owner>.github.io | https://<owner>.github.io/ | https://<owner>.github.io |
| Any other name | https://<owner>.github.io/<repo>/ | https://<owner>.github.io/<repo> |
With a public/CNAME | https://<your-domain>/ | https://<your-domain> |
Set it in app/docs/_docs.ts:
export const docs = new Docs({
contentDir: "./docs",
siteUrl: "https://<owner>.github.io/<repo>",
brandName: "Example",
});
If a sibling _blog.ts shares the origin, update it too.
Include the subpath. For a project site, siteUrl carries the /<repo> segment, and scribekit
prepends it to every absolute URL it builds - canonicals and OpenGraph resolve through Next's
metadataBase, and the sitemap and JSON-LD go through the same join. Set it as the table says and
a project site's metadata is correct; there is nothing further to work around.
Root hosting is still simpler if you have the choice, and either of these gets you there:
- A custom domain. Add
public/CNAMEcontaining the domain, point DNS at Pages, and setsiteUrlto it. The base path becomes empty. - A user or org site. Name the repo
<owner>.github.io. Also root.
6. Check for middleware
If your app has a middleware.ts or proxy.ts that rewrites clean locale URLs (/docs/... onto
/en/docs/...), static export drops it. Middleware does not exist on a static host. Only the
prefixed paths get generated, and every bare /docs/... URL 404s.
The fix is to stop needing the rewrite: set prefixDefaultLocale: true on your Docs instance so
every language, including the default, is served under its own prefix. Then delete the
middleware and remove any /en-stripping your chrome did on usePathname(), since it assumed the
rewrite. URLs become /<lang>/docs/... across the board - the honest trade for static hosting.
A single-locale site has no middleware and nothing to do here. If you followed
Build a docs site from scratch, skip this step: the flat /docs/...
export just works.
One more honesty note while you are here: if you use the redirects config for renamed slugs,
those stop being true 308s. The build still succeeds and real visitors still land on the new
URL - Next turns the redirect into a client-side one that fires after hydration. But to a crawler
or a JS-less client, the old URL is a blank 200 with no 308 and no canonical. If those old URLs
matter for SEO, serve public/<oldpath>.html meta-refresh stubs instead, and drop both
getRedirectRefs() from generateStaticParams and the permanentRedirect branch from the
route, so it stops emitting those pages. You cannot keep both: a public/ file and a prerendered
route at the same path collide. Give each stub a <link rel="canonical" href="<newUrl>"> next to
its meta refresh, and remember that on a project site the stub's target must carry the base path
too, or it 404s.
7. Push and watch it go live
git add next.config.mjs public/.nojekyll .github/workflows/deploy.yml \
app/docs/_docs.ts app/docs/_docs-image.tsx app/docs/_docs-links.tsx app/docs/[slug]/page.tsx
git commit -m "Publish docs to GitHub Pages"
git push
Open the repository's Actions tab. The "Deploy to GitHub Pages" run appears with two jobs,
build then deploy. When both go green - about a minute or two - the deploy job shows the live
URL, and Settings → Pages shows it too.
Open it. Your docs index loads, the sidebar and tab bar are there, ⌘K opens the palette, and prev/next walks the corpus. The client components hydrate straight from the static HTML, so nothing about the shell degrades.
If the deploy job fails with "Get Pages site failed" or a not-found error, an org policy has
blocked the workflow's enablement: true. Enable Pages once by hand - Settings → Pages → Build
and deployment → Source → select "GitHub Actions" - and re-run the workflow. After that the
setting sticks and the step is a no-op.
If assets 404 and the page renders unstyled, that is the base-path tell: check that
next.config.mjs reads NEXT_PUBLIC_BASE_PATH (step 1) and that the workflow's build step passes
NEXT_PUBLIC_BASE_PATH: ${{ steps.pages.outputs.base_path }} (step 3). A missing env var is the
usual cause. If the page is styled but the hero image is the only 404, you skipped step 4.
If the build job fails with "It looks like you're trying to use TypeScript but do not have the
required package(s) installed", your app's package.json is missing the TypeScript toolchain.
Locally next build offers to install it; the workflow runs npx --no-install next build, so it
cannot. Add it to the app's own package.json - a repo-root one does not count for an app in a
subdirectory - and commit the updated lockfile:
npm install --save-dev typescript @types/node @types/react @types/react-dom
What you have now
A live docs site on GitHub Pages that rebuilds and redeploys on every push to your default branch,
with correct canonical, OpenGraph, and sitemap URLs whether you are root-hosted or under a project
subpath. Writing a new page is a new folder under docs/ and a push.
To sanity-check the export before pushing, run npx next build from your app root and confirm
out/ holds your docs HTML, a populated _next/, and .nojekyll.