ScribekitDocs

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.

11 min readUpdated 11 August 2026
Publish your docs to GitHub Pages

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 hardcoded basePath: "/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 not main, change it.
  • App in a subdirectory. If your Next app lives below the repo root (say web/), add a defaults: { run: { working-directory: web } } block to the build job, change the upload path: to ./web/out, and add cache-dependency-path: web/package-lock.json under setup-node's with:.
  • No lockfile. Both npm ci and cache: npm need a committed package-lock.json. If you have none, commit one, or drop the cache: npm line and change npm ci to npm install.
  • TypeScript app. Your app's own package.json must list typescript, @types/node, @types/react, and @types/react-dom. The workflow runs npx --no-install next build, so it cannot install them for you, and a repo-root package.json does 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.

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:

RepositoryLive atsiteUrl
Named <owner>.github.iohttps://<owner>.github.io/https://<owner>.github.io
Any other namehttps://<owner>.github.io/<repo>/https://<owner>.github.io/<repo>
With a public/CNAMEhttps://<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/CNAME containing the domain, point DNS at Pages, and set siteUrl to 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.

Was this page helpful?