Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

Hosting

Serve a separate docs site under /docs in Next.js

Deploy Blume as its own static site under /docs, proxy it through one Next.js rewrite, and check pages, assets, canonical URLs, and llms.txt through your domain.

By 8 min read

Yes. Build your Blume docs as their own static site with deployment.base set to /docs, deploy it to its own origin, and add one rewrite to your Next.js app that proxies /docs and everything under it to that origin. Readers stay on your domain, your app keeps every other path, and the two deploy separately.

By the end, www.acme.example serves your Next.js app and www.acme.example/docs serves the docs, with canonical URLs, the sitemap, and llms.txt naming the public domain, plus a script that checks pages, assets, and machine-readable files through it. Both apps deploy on Vercel here. The docs origin can be any static host that serves /docs/quickstart without adding a trailing slash.

If a subdomain like docs.acme.example is fine, skip all of this and deploy Blume on its own. If the docs must share your app's React layout and client-side navigation, a separate app is the wrong shape: every move between the two is a full page load. This guide also keeps the docs static. The assistant and the MCP server need a server build, which it doesn't cover (see static or server-rendered docs).

Choose deployment.base, not basePath

Blume has two settings that put pages under /docs, and they move different things:

Served atdeployment.base: "/docs"basePath: "/docs"
A page/docs/quickstart/docs/quickstart
Built CSS and JavaScript/docs/_astro//_astro/
A file in public//docs/logo.svg/logo.svg
The search index/docs/blume-search.json/blume-search.json
llms.txt, sitemap.xml, robots.txtUnder /docs/At the root

With basePath, the docs still need paths at the root, where they collide with your app's own /robots.txt, /sitemap.xml, and public files, and each one needs its own rewrite. With deployment.base, everything the docs serve starts with /docs, so one rewrite covers it. This guide uses deployment.base alone.

Configure the docs site

Create the docs project beside your app, in its own repository or a folder of a monorepo (the monorepo guide covers that layout):

npx blume init acme-docs

Add a page and a nested page, so there's something to test at two depths. The first links with a root-relative path, which Blume rewrites to include the base. The second links back to the app with a full URL:

---
title: Quickstart
description: Send your first message with the Acme Messages API.
---

Create an API key, then set up [webhooks](/guides/webhooks).
---
title: Webhooks
description: Receive delivery events for the messages you send.
---

Webhooks are included on every [plan](https://www.acme.example/pricing).

Then set the deployment and the links back to your app:

import { defineConfig } from "blume";

export default defineConfig({
  title: "Acme Docs",
  description: "Guides and reference for the Acme Messages API.",
  deployment: {
    // The public origin, with no path.
    site: "https://www.acme.example",
    // Every page, asset, and generated file moves under /docs.
    base: "/docs",
  },
  // Optional: send the logo to your app's home instead of the docs home.
  logo: { href: "https://www.acme.example" },
  navigation: {
    actions: [{ href: "https://www.acme.example/pricing", label: "Pricing" }],
    cta: { href: "https://www.acme.example/signup", label: "Sign up" },
  },
});

Run npx blume dev and open http://localhost:4321/docs/quickstart. Its webhooks link points at /docs/guides/webhooks, though you wrote /guides/webhooks.

Deploy the docs origin

deployment.base changes the URLs inside the build, not where files sit. blume build still writes dist/quickstart/index.html, and expects the host to serve dist/ at /docs. So the origin's build copies it into a docs folder, plus Blume's not-found page at the top level, where the host looks for it. Add a script to the docs project's package.json:

"scripts": {
  "dev": "blume dev",
  "build": "blume build",
  "build:origin": "blume build && rm -rf out && mkdir out && cp -R dist out/docs && cp dist/404.html out/404.html"
}

Add out to .gitignore, and tell Vercel to run the script and serve that folder:

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "buildCommand": "npm run build:origin",
  "outputDirectory": "out"
}

Import the docs repository in Vercel as its own project (in a monorepo, set its root directory to acme-docs), with Node.js 22 or later. Once it deploys, open /docs on the production domain listed in the project's domain settings, like https://acme-docs.vercel.app/docs. The docs should load with their styles straight from the origin. Branch previews work the same way, so you can review a docs change before it reaches your domain.

Add the rewrite in Next.js

In your Next.js app, proxy /docs to the origin:

import type { NextConfig } from "next";

// The docs project's production domain, with no trailing slash.
const DOCS_ORIGIN = process.env.DOCS_ORIGIN ?? "https://acme-docs.vercel.app";

const nextConfig: NextConfig = {
  async rewrites() {
    return [
      // :path* matches zero or more segments, so /docs itself goes too.
      { source: "/docs/:path*", destination: `${DOCS_ORIGIN}/docs/:path*` },
    ];
  },
};

export default nextConfig;

The path passes through unchanged, which is why the origin serves the build under /docs. Because rewrites() returns an array, Next.js checks its own pages and public/ files first, so remove any app/docs route or public/docs folder. Set DOCS_ORIGIN in the Next.js project's environment variables. Next.js writes rewrites into the build output, so redeploy the app after changing it.

Redirects

For each exact redirect in blume.config.ts, Blume writes a redirect page, and those work through the rewrite. For a real HTTP status, repeat the redirect in next.config.ts, using the public path. Next.js checks redirects before rewrites:

async redirects() {
  return [
    { source: "/docs/getting-started", destination: "/docs/quickstart", permanent: true },
  ];
},

Files that belong at the root

Crawlers only read robots.txt at the root of a host, so the one Blume writes to /docs/robots.txt is ignored. Your app answers /robots.txt, so point it at the docs sitemap (pass an array if your app has a sitemap of its own):

import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: "https://www.acme.example/docs/sitemap.xml",
  };
}

The same goes for anything that has to sit at the domain root: Blume's .well-known files are under /docs/.well-known/.

From Next.js to the docs, use a plain <a>, not <Link>. Next.js tries to prefetch and soft-navigate any relative path in a <Link>, which doesn't work across apps:

import Link from "next/link";

export function SiteNav() {
  return (
    <nav>
      <Link href="/pricing">Pricing</Link>
      {/* /docs is another app, so the browser should load it. */}
      <a href="/docs">Docs</a>
    </nav>
  );
}

From the docs to your app, write full URLs, as the config above does. Blume adds the base to a root-relative link, so /pricing becomes /docs/pricing, and a header link to a route Blume doesn't serve draws a build warning. Full URLs in header actions, the call to action, and footer links open in a new tab, because Blume treats every full URL there as external. In Markdown and on the logo, they open in the same tab. Blume's client-side router fetches a same-origin link before swapping it in, and when the response isn't a Blume page, it falls back to a normal page load, so these links land on your app.

Trailing slashes and canonical URLs

Every Blume page has one URL, without a trailing slash, and its canonical tag, sitemap entry, and llms.txt link all use that form. Next.js already agrees: by default it answers /docs/quickstart/ with a 308 to /docs/quickstart before any rewrite runs, so the origin only ever sees the slashless path. Leave trailingSlash unset in next.config.ts. Set to true, it would redirect every docs page to a URL its canonical tag doesn't name.

The origin must then serve /docs/quickstart from docs/quickstart/index.html without redirecting. Vercel does by default. A host that adds the slash loops with Next.js (see Troubleshooting).

Absolute URLs are site, then the base, then the route, so the quickstart's canonical is https://www.acme.example/docs/quickstart. The Open Graph image, sitemap, and llms.txt follow the same rule. That's why site is set by hand: on Vercel, Blume would otherwise detect the docs project's own production domain, and every canonical would name the origin. The copy readers can reach at the origin carries the same canonical, pointing back to your domain.

Test through your domain

Run both apps locally first. blume preview serves the build at http://localhost:4321/docs, the same paths the origin serves:

# In acme-docs
npx blume build
npx blume preview

# In your Next.js app, in a second terminal
DOCS_ORIGIN=http://localhost:4321 npm run dev

Then save this script and run bash check-docs.sh http://localhost:3000 to check every kind of path through the Next.js app:

#!/usr/bin/env bash
# Usage: ./check-docs.sh http://localhost:3000
SITE="${1:?Pass the origin to check, like https://www.acme.example}"

check() {
  code=$(curl -s -o /dev/null -w "%{http_code}" "$SITE$1")
  if [ "$code" = "$2" ]; then echo "ok    $code $1"; else echo "FAIL  $code $1 (expected $2)"; fi
}

check /docs 200
check /docs/ 308
check /docs/quickstart 200
check /docs/quickstart/ 308
check /docs/guides/webhooks 200
check /docs/quickstart.md 200
check /docs/index.md 200
check /docs/llms.txt 200
check /docs/sitemap.xml 200
check /docs/blume-search.json 200
check /docs/no-such-page 404
check /robots.txt 200

# The first built asset the page loads.
asset=$(curl -s "$SITE/docs/quickstart" | grep -o '/docs/_astro/[^"]*' | head -1)
check "$asset" 200

# Absolute URLs: each should start with https://www.acme.example/docs.
curl -s "$SITE/docs/quickstart" | grep -o '<link[^>]*rel="canonical"[^>]*>'
curl -s "$SITE/docs/sitemap.xml" | grep -o '<loc>[^<]*' | head -3
curl -s "$SITE/robots.txt" | grep -i '^sitemap'
RowsWhat they prove
/docs, a page, a nested pageThe rewrite reaches the docs home and pages at any depth.
The two slashed pathsNext.js drops the trailing slash before proxying.
The _astro asset and search indexStyles, scripts, and search load under the base.
.md, llms.txt, sitemap.xmlThe machine-readable files agents and crawlers fetch.
/docs/no-such-pageA miss is a real 404 with Blume's not-found page.
/robots.txt and the last three linesYour app owns the root, and every absolute URL names your domain.

Once both apps are deployed, run it again with https://www.acme.example. On this static origin, agents fetch the .md URLs directly: answering Accept: text/markdown at the page's own URL needs a Vercel or Cloudflare server build.

Troubleshooting

Every docs page fails with too many redirects

The docs host redirects /docs/quickstart to /docs/quickstart/, and Next.js redirects it straight back. Turn off the host's trailing-slash redirect, or use a host that serves a folder's index.html at the slashless path, as Vercel does.

Docs pages 404 or load without styles

If every docs URL shows a not-found page, the origin is serving dist/ at its root instead of out/: check the build command and output directory. If pages load but their CSS and JavaScript 404, deployment.base isn't set, so the page asks your app for /_astro/ files it doesn't have. Either way, open /docs/quickstart on the origin itself first: it should render with styles before you debug the rewrite.

The docs show a Vercel login page

DOCS_ORIGIN names a deployment or branch URL. Vercel's Standard Protection covers every URL except production domains, so the rewrite gets the login page. Use the production domain from the docs project's domain settings.

Docs requests redirect to your sign-in page

Your proxy.ts (formerly middleware.ts) runs before rewrites, and its matcher covers /docs. Exclude it:

export const config = {
  matcher: [
    "/((?!docs|api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

Canonical URLs name the wrong domain

If they name the origin, site isn't set and Blume detected the docs project's domain. If they read /docs/docs/, site includes the path. Set it to the bare origin, https://www.acme.example, and rebuild.

blume audit reports links to your app as broken

blume audit reads only the docs build, so a full URL on your domain outside /docs looks unserved and reports BLUME_AUDIT_LINK_TO_BROKEN. Check those links through your domain instead. Skipping the check with --skip=BLUME_AUDIT_LINK_TO_BROKEN hides real broken links too.

Next step

Start the docs project

Scaffold it beside your Next.js app, then set deployment.base and site as this guide does.

npx blume init acme-docs
Read the deployment reference

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init