Agent-readable docs index: /llms.txt. Download /docs.zip to grep all markdown files locally.

Custom Entry

Holocron can be mounted as a child app inside your existing Spiceflow project. This lets you ship docs, API routes, auth, webhooks, and custom pages all from a single server.

When to use this

  • You already have a Spiceflow app and want to add a /docs section
  • You need middleware (auth, logging, headers) to wrap both your routes and the docs
  • You want API routes like /api/chat living next to your documentation

Keep docs in a subfolder

When mounting Holocron alongside your own app, always put MDX files inside a subfolder like docs/. This way all documentation lives under /docs/* and won't collide with your app routes like /api, /login, /dashboard, or /pricing.
Without a subfolder, a page like configuration.mdx maps to /configuration, which could easily conflict with a current or future app route. With a docs/ prefix it becomes /docs/configuration and stays cleanly isolated. As your app grows you never have to worry about a new feature route clashing with a docs page.
my-project/ ├── docs/ ← all MDX pages live here │ ├── getting-started.mdx │ └── configuration.mdx ├── docs.json ├── server.tsx ├── vite.config.ts └── package.json
Reference pages in docs.json with the docs/ prefix:
{ "navigation": [ { "group": "Guides", "pages": ["docs/getting-started", "docs/configuration"] } ] }
Page slugs map directly to file paths. docs/getting-started resolves to docs/getting-started.mdx and is served at /docs/getting-started.

Set a docs/ base for OpenAPI and Changelog tabs

OpenAPI and Changelog tabs generate their pages from a base slug prefix, not from files on disk. The defaults put them at the root of your domain: OpenAPI endpoints land at /api/* and the changelog lands at /changelog.
In a standalone docs site that is fine. In a custom entry app it is a problem, because those generated pages now sit at the root of your real product domain right next to /api, /login, and /dashboard. The OpenAPI default is the most dangerous one: base: "api" means the generated reference pages collide directly with your actual API routes like /api/users.
Always prefix these base values with docs/ so the generated pages stay under your docs namespace:
{ "navigation": { "tabs": [ { "tab": "API Reference", "openapi": "openapi.json", // ✅ generates /docs/api/* instead of /api/* (which collides with your real API) "base": "/docs/api" }, { "tab": "Changelog", "changelog": "https://github.com/acme/acme", // ✅ generates /docs/changelog instead of /changelog "base": "/docs/changelog" } ] } }
This keeps every part of the docs, MDX pages, OpenAPI reference, and the changelog, under one predictable /docs/* namespace, leaving your app's root free for product routes.
The OpenAPI base defaults to "api", so without an override your generated reference pages are served at /api/* and will collide with your real API routes. Always set base: "/docs/api" (or another docs/-prefixed slug) in a custom entry app.

Setup

Pass entry to the holocron plugin pointing to your Spiceflow server file:
// vite.config.ts import { defineConfig } from 'vite' import { holocron } from '@holocron.so/vite' export default defineConfig({ plugins: [ holocron({ entry: './src/server.tsx' }), ], })
Then in your server file, import the holocron app and mount it with .use():
// src/server.tsx import { Spiceflow } from 'spiceflow' import { app as holocronApp } from '@holocron.so/vite/app' export const app = new Spiceflow() // your middleware runs on every request, including docs pages .use(async ({ request }, next) => { const res = await next() if (res) res.headers.set('x-custom-header', 'yes') return res }) // your own API routes .get('/api/hello', () => ({ hello: 'world' })) .get('/api/echo/:name', ({ params }) => ({ name: params.name })) // your own pages with your own layouts .layout('/dashboard', ({ children }) => ( <html lang='en'> <head><title>Dashboard</title></head> <body>{children}</body> </html> )) .page('/dashboard', () => <h1>My Dashboard</h1>) // mount holocron last — it handles all docs pages .use(holocronApp) void app.listen(3000)
Holocron registers routes for every page in your docs.json navigation. Your own routes take priority because they're registered first.

Middleware

Middleware registered with .use() before .use(holocronApp) runs on every request, including doc pages. This is useful for auth checks, analytics headers, or request logging.
export const app = new Spiceflow() .use(async ({ request }, next) => { console.log(request.method, request.url) return next() }) .use(holocronApp)

Testing with Vitest

Your custom entry imports @holocron.so/vite/app. The holocron() plugin aliases that import to its source app and provides the virtual:holocron-config, virtual:holocron-navigation, virtual:holocron-mdx, and virtual:holocron-modules modules. In normal dev and build the plugin is always present, so this just works.
In tests it is easy to drop the plugin by accident. If you build a separate Vitest config without holocron(), importing your server file fails at module load:
Error: Cannot find package 'virtual:holocron-config'
The fix is to keep holocron() in your test Vite config so the alias and virtual modules exist there too. Point its entry at the same server file you ship.
// vite.config.ts import { defineConfig } from 'vite' import { holocron } from '@holocron.so/vite' export default defineConfig({ plugins: [ holocron({ entry: './src/server.tsx' }), ], test: { // your Vitest options }, })

Cloudflare Workers tests

When testing a Workers custom entry with @cloudflare/vitest-pool-workers, the pool wants the raw react and spiceflow plugins so it can run your app inside workerd, while cloudflare() must be off in tests (the pool manages workerd itself). You still need holocron() for the alias and virtual modules.
These coexist. The holocron() plugin detects an already-installed React, Spiceflow, Tailwind, or Cloudflare plugin and skips adding its own duplicate, so listing them yourself is safe.
// vite.config.ts import { defineConfig } from 'vite' import { cloudflareTest } from '@cloudflare/vitest-pool-workers' import react from '@vitejs/plugin-react' import { spiceflowPlugin } from 'spiceflow/vite' import { holocron } from '@holocron.so/vite' const isTest = !!process.env.VITEST export default defineConfig({ plugins: [ isTest ? cloudflareTest({ wrangler: { configPath: './wrangler.test.jsonc' } }) : null, // In tests: raw react + spiceflow so the pool can run the app, plus holocron // for the `@holocron.so/vite/app` alias and `virtual:*` modules. holocron // sees the raw plugins and skips re-adding them. react(), spiceflowPlugin({ entry: './src/server.tsx' }), holocron({ entry: './src/server.tsx' }), ], })
Do not load cloudflare() (the deploy plugin) in test mode. Both it and the Workers test pool manage workerd, and running them together conflicts. Keep cloudflare() for dev/build only and use cloudflareTest() for tests.

Custom CSS

Holocron includes Tailwind CSS automatically. Do not add @import 'tailwindcss' in your own CSS files. A second import creates duplicate style layers that break layout, spacing, and cascade order.

Tailwind references

Use @reference instead of @import. It gives your CSS access to Tailwind's theme variables, @apply, and @variant without emitting duplicate styles:
/* src/globals.css */ @reference "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); :root { --primary: #e11d48; --background: #fafafa; @variant dark { --background: #0a0a0a; --primary: #fb7185; } }

Dark mode variant limitation

@custom-variant dark must be repeated in every Tailwind-processed CSS file that uses @variant dark. This is a Tailwind limitation: @custom-variant is a compile-time directive, not a runtime CSS variable or inherited browser setting.
Holocron defines the dark variant in its own stylesheet, but that only affects CSS compiled in Holocron's Tailwind processing context. Your custom entry CSS is processed as a separate stylesheet, so Tailwind does not automatically know that Holocron uses class-based dark mode.

Required dark variant

If you omit this line:
@custom-variant dark (&:where(.dark, .dark *));
then @variant dark in your CSS can compile to Tailwind's default dark behavior, which may follow the browser system media query. That makes your app theme disagree with Holocron's persisted toggle, because Holocron stores the selected theme by adding or removing .dark on <html>.

Shared theme cookie

Holocron persists the selected mode in the color-theme cookie and mirrors it on <html class="dark">. If your app has its own theme toggle outside the docs layout, use the same cookie so both your pages and Holocron pages share one persisted state:
'use client' function setTheme(theme: 'light' | 'dark') { document.documentElement.classList.toggle('dark', theme === 'dark') document.cookie = `color-theme=${theme}; Path=/; Max-Age=31536000; SameSite=Lax` } export function ThemeToggle() { function toggle() { const isDark = document.documentElement.classList.contains('dark') setTheme(isDark ? 'light' : 'dark') } return <button onClick={toggle}>Toggle theme</button> }

Server-rendered theme class

If your custom pages render their own <html> shell, read the same cookie on the server and set the initial class before the page paints:
function getInitialThemeClass(request: Request) { const cookie = request.headers.get('cookie') ?? '' return /(?:^|;\s*)color-theme=dark(?:;|$)/.test(cookie) ? 'dark' : undefined } export const app = new Spiceflow() .layout('/dashboard/*', ({ children, request }) => ( <html lang='en' className={getInitialThemeClass(request)} suppressHydrationWarning> <body>{children}</body> </html> ))

Importing custom CSS

If your CSS file is not named global.css or style.css at the project root, import it normally in your server entry:
import './globals.css'
Your custom properties override Holocron's defaults because Holocron puts its default tokens in a low-priority cascade layer. Normal unlayered app CSS wins regardless of stylesheet load order.

Custom homepage

With docs nested in docs/, your / route stays free for a custom homepage. Holocron will not redirect / to the first doc page when a parent route already handles it.
// server.tsx import { Spiceflow } from 'spiceflow' import { app as holocronApp } from '@holocron.so/vite/app' export const app = new Spiceflow() .page('/', () => ( <html lang='en'> <head><title>My Product</title></head> <body> <h1>Welcome to My Product</h1> <a href='/docs/getting-started'>Read the docs</a> </body> </html> )) .get('/api/hello', () => ({ hello: 'world' })) .use(holocronApp) export default { fetch(request: Request) { return app.handle(request) }, }

Real-world example

The holocron.so website uses this pattern. It mounts holocron docs alongside auth routes (better-auth with Google login), an AI gateway proxy, and a device authorization flow.