/scribekit-docs-github-pages
Reference for the /scribekit-docs-github-pages skill: its two modes, its eight steps, the deploy workflow it writes, and the static-export trade-offs it will not paper over.

/scribekit-docs-github-pages publishes a Scribekit docs corpus to GitHub Pages as a static export - whether the project already has a Next.js app rendering it, or is just a bare docs/<slug>/*.mdx folder with nothing to render it. It learns the project first, scaffolds the host app if there is none, then patches the Next.js config, writes the deploy workflow, sets siteUrl to the Pages origin, and hands you back a push away from live.
Not using Claude Code?
There is an escape hatch that needs no agent at all: the deploy workflow is a plain file, copyable straight from node_modules/@daanvandenbergh/scribekit/skills/scribekit-docs-github-pages/assets/deploy.yml to .github/workflows/deploy.yml. It is not quite standalone, though - its own header names the prerequisite: next.config.* must read NEXT_PUBLIC_BASE_PATH, or a project site never receives its base path. Publish your docs to GitHub Pages walks the whole thing by hand.
Invoke the skill
/scribekit-docs-github-pages [--verify]
--verify additionally runs the local export build and the browser check in Step 7; without it, Step 7 is a config sanity check only.
Modes
There is no mode argument. Step 0 picks one of two paths from what it finds:
| Mode | Chosen when | What it adds |
|---|---|---|
| Deploy | A new Docs(...) instance and route files already render the corpus | Nothing extra. Straight to the eight steps. |
| Create | A docs/<slug>/*.mdx corpus exists but no Docs instance and no routes | Scaffolds the host app from assets/app-template/ first, then runs the eight steps on it. |
If there is neither a Docs instance nor a corpus, the skill stops and points you at /scribekit-docs. It never writes docs content itself.
What Create mode scaffolds
Copied from assets/app-template/ and filled in from the corpus it just read: _docs.ts (with contentDir, brandName, and the tabs / groups arrays derived from every distinct front-matter tab and group, in order), _docs-links.tsx, _docs-image.tsx, _docs-chrome.tsx, layout.tsx, page.tsx, and slug-page.tsx as app/docs/[slug]/page.tsx. It then adds the two files the template does not carry: a root app/layout.tsx importing both @daanvandenbergh/scribekit/styles.css and the template's globals.css, and a package.json whose devDependencies include typescript, @types/node, @types/react, and @types/react-dom - without them CI's npx --no-install next build aborts outright. A multi-locale corpus gets the [lang] tree with prefixDefaultLocale: true instead, since a static host has no middleware for clean default-locale URLs.
What it is not
This is not a content skill. Writing, rewriting, or reorganizing docs pages is /scribekit-docs. It never edits MDX page bodies, front-matter, slugs, or the nav in either mode. In Deploy mode it edits deploy config exclusively: next.config.*, the _docs.ts siteUrl and routing flags, .github/, and public/. Create mode additionally creates the host app - route files, layout, chrome, _docs.ts, next.config.*, package.json - but still stops at the content boundary.
For the guided walkthrough, see Publish to GitHub Pages.
The eight steps
| Step | What it does |
|---|---|
| Step 0 | Learn the project: find the Docs wiring (contentDir, siteUrl, basePath, locales, defaultLocale, prefixDefaultLocale, redirects), find the Next app root, derive the Pages origin and base path from git remote get-url origin, and detect the static-export blockers present. Picks Deploy or Create here; stops only if there is no corpus at all. |
| Step 1 | Patch next.config.*: merge in output: "export", trailingSlash: true, images: { unoptimized: true }, and a basePath / assetPrefix pair derived from process.env.NEXT_PUBLIC_BASE_PATH. |
| Step 2 | Create an empty public/.nojekyll so the exported _next/ folder is never treated as Jekyll source. |
| Step 3 | Copy assets/deploy.yml to .github/workflows/deploy.yml at the git repo root, not the app subdir. Workflows only run from the repo root's .github/. |
| Step 4 | Set siteUrl in _docs.ts to the derived Pages origin, so canonical, sitemap, hreflang, and OG URLs point at the real site. The domain is confirmed with the user first. |
| Step 5 | Fix the blockers found in Step 0, and only those present. |
| Step 6 | Enable Pages, then push. The workflow self-enables; report the manual fallback only if an org policy blocks it. |
| Step 7 | Verify. Config sanity always; the full build and browser check with --verify. |
Step 0 derives the origin three ways: a repo named exactly <owner>.github.io is a user/org site served at the root with an empty base path; a public/CNAME file means a custom domain, also root-served with an empty base path; anything else is a project site under https://<owner>.github.io/<repo>/ with a base path of /<repo>.
Why it is mostly config, not code
A Scribekit docs site is already static-export-ready:
- The
Docsclass reads the filesystem at build time. - Pages render as React Server Components with
next-mdx-remote/rsc. - Search is client-side Fuse.js.
- Every dynamic route already ships
generateStaticParamsandexport const dynamicParams = false.
So next build with output: "export" prerenders the whole site to static HTML. The only things affected are two routing-layer conveniences a project may layer on top, handled in Step 5.
Note that output: "export" exports the whole Next app. A Blog section on the same app is exported too and is subject to the same rules.
What the workflow contains
assets/deploy.yml triggers on push to branches: [main] plus workflow_dispatch, takes least-privilege permissions (contents: read, pages: write, id-token: write), and runs one deploy at a time with concurrency: { group: pages, cancel-in-progress: false }.
The build job on ubuntu-latest:
| Step | Action or command |
|---|---|
| Checkout | actions/checkout@v7 |
| Set up Node | actions/setup-node@v7 with node-version: 22 and cache: npm |
| Configure Pages | actions/configure-pages@v6 with enablement: true - and deliberately no static_site_generator |
| Install dependencies | npm ci |
| Build static export | npx --no-install next build, with env: NEXT_PUBLIC_BASE_PATH: ${{ steps.pages.outputs.base_path }} |
| Upload artifact | actions/upload-pages-artifact@v5 with path: ./out |
The deploy job needs build, targets the github-pages environment, and runs actions/deploy-pages@v5.
static_site_generator: next is left out on purpose. That mode lets the action inject basePath itself, which would leave the app unable to read the same value - and _docs-image.tsx's hero <img> needs it. Instead configure-pages@v6 only emits base_path, the build step passes it in as NEXT_PUBLIC_BASE_PATH, and the app's own next.config.* and hero component both read it. enablement: true turns Pages on for the repository on the first run.
The template carries a commented-out defaults.run.working-directory block for an app in a subdirectory; uncommenting it also means changing the upload path: to <subdir>/out and adding cache-dependency-path: <subdir>/package-lock.json. Both npm ci and setup-node's cache: npm need a committed package-lock.json. A TypeScript app must carry typescript, @types/node, @types/react, and @types/react-dom in its own package.json - a repo-root one does not count for an app in a subdirectory, and npx --no-install cannot fetch them. If the repo's default branch is not main, the trigger changes. If .github/workflows/deploy.yml already exists, the skill shows the diff and asks before overwriting.
The static-export trade-offs
Being honest about these is one of the skill's non-negotiables.
Middleware clean-URLs are a hard blocker
A proxy.ts or middleware.ts that rewrites /docs/... onto a /<defaultLocale>/docs/... route tree cannot run on a static host. Static export drops middleware entirely, so only the prefixed paths (/en/docs/..., /fr/docs/...) get generated and the bare /docs/... clean URLs 404.
The clean fix is prefixDefaultLocale: true on the Docs (and Blog) instance, so every locale including the default is served under its own prefix with no middleware. That means deleting the proxy.ts or middleware.ts, removing any /en-stripping the client chrome did on usePathname() (it assumed the rewrite), and optionally adding a static entry redirect from /docs to /<defaultLocale>/docs. URLs become /<lang>/docs/... for every language: the honest trade for static hosting.
A single-locale site usually has no middleware and nothing to do here. The flat /docs/... export just works.
Runtime slug redirects degrade, but do not hard-break
A redirects config served via permanentRedirect() in the [slug] route still builds under output: "export". Next turns the 308 into a client-side redirect: the old slug exports as a near-empty 200 HTML page carrying a NEXT_REDIRECT marker in its payload, and once its JS hydrates the router navigates to the new URL and applies the deployment base path. Renamed slugs do redirect for real visitors.
The loss is SEO and no-JS. To a crawler or a JS-less client, the old URL is a blank 200 with no 308, no <meta refresh>, and no canonical: a weak signal. There is no server on Pages, so a true 308 is not available either way. Two options:
| Option | Trade |
|---|---|
| Leave it | Zero work. JS visitors get redirected. Correct when the old URLs are not SEO-critical. |
| Static stubs | Drop ...getRedirectRefs() from the route's generateStaticParams and the permanentRedirect branch, then write a public/<oldpath>.html stub with <meta http-equiv="refresh" content="0; url=<newUrl>"> and <link rel="canonical" href="<newUrl>">. |
You cannot keep both: a public/ file and a prerendered route at the same path collide. getRedirect(slug, lang) returns <newUrl> rooted at the docs section base path (/docs/...); on a project site the stub must also carry the deployment base path (/<repo>) or it lands on a 404.
The project-site base path takes three pieces of plumbing
On a project site at https://<owner>.github.io/<repo>/, everything under /<repo> has to be prefixed, and three different things need three different mechanisms. The skill wires all three in Step 5, and a project site then renders correctly with no custom domain required:
| What | How it gets the base path |
|---|---|
Routes, next/link navigation, _next/ assets | Next's own basePath / assetPrefix, set in next.config.* from NEXT_PUBLIC_BASE_PATH (Step 1) |
| In-body prose links in MDX | components={{ a: BodyLink }} - raw <a href="/..."> would otherwise bypass next/link entirely |
The hero image from image: front-matter | imgComponent={BaseImg}, which prepends NEXT_PUBLIC_BASE_PATH itself - next/image will not, once images.unoptimized is set |
In Deploy mode the skill adds imgComponent={BaseImg} and components={{ a: BodyLink }} to the existing [slug] route, alongside the Step 1 config change. In Create mode the template already ships both.
Absolute SEO URLs need nothing extra: siteUrl carries the /<repo> segment and Scribekit prepends it, so canonical, sitemap, hreflang, and OG all come out correct under a subpath.
Root hosting is still one less moving part if you have the choice - a custom domain via public/CNAME, or a <owner>.github.io user/org repo - and there the base path is simply empty.
Enabling Pages
The workflow's configure-pages step sets enablement: true, so on its first run it turns Pages on itself (build type "GitHub Actions"). In the common case there is no manual step: commit, push to the default branch (or run the workflow from the Actions tab), and the site publishes at the Pages URL derived in Step 0.
The exception is an org policy that blocks Actions from enabling Pages. That first run fails with "Get Pages site failed / Not Found", and Step 6 tells you the one-time fix:
GitHub -> repo Settings -> Pages -> Build and deployment -> Source: "GitHub Actions".
Re-run the workflow after that and it deploys. With Pages already enabled, enablement: true is a no-op.
Anti-rules
- Do not hardcode a literal base path in
next.config.*. DerivebasePath/assetPrefixfromprocess.env.NEXT_PUBLIC_BASE_PATH, which the workflow feeds fromconfigure-pages'sbase_pathoutput. A literalbasePath: "/my-repo"breaks local builds and every other host. - Do not swap in a workflow that relies on
static_site_generator: next. The app has to own the base path, or the hero<img>cannot read it. - Do not set
trailingSlashon one side only. Step 1 writestrailingSlash: true, matching Scribekit's own default: the export then emitsdocs/<slug>/index.html, so Pages serves/docs/<slug>/and redirects/docs/<slug>to it. A project that already runstrailingSlash: falsekeeps it and setstrailingSlash: falseon itsDocs/Bloginstance instead. Flipping one without the other makes every canonical,hreflang, sitemap entry, and nav link disagree with the URL the host serves - silently, since nothing throws.
Constraints
- Preserve the user's config. Merge into
next.config.*and_docs.ts; never rewrite the file from scratch or drop existing keys. Every edit is idempotent, so re-running the skill does not duplicate keys, workflows, or stub pages. - Do not touch docs content. No editing MDX bodies, front-matter, slugs, or the nav - in either mode. Create mode writes the app around the corpus, never the corpus.
- Be honest about static-export limits. Middleware clean-URLs and true 308 redirects are the real trade-offs; they are losses a setting cannot buy back.
- Confirm before destructive or outward-facing edits. Overwriting an existing workflow, deleting a
proxy.tsormiddleware.ts, and changing the public URL scheme viaprefixDefaultLocaleall change how the live site behaves. - Never start a dev server. Never create git branches.