/docs section/api/chat living next to your documentationdocs/. This way all documentation lives under /docs/* and won't collide with your app routes like /api, /login, /dashboard, or /pricing.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
docs.json with the docs/ prefix:12345678{ "navigation": [ { "group": "Guides", "pages": ["docs/getting-started", "docs/configuration"] } ] }
docs/getting-started resolves to docs/getting-started.mdx and is served at /docs/getting-started.docs/ base for OpenAPI and Changelog tabsbase 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./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.base values with docs/ so the generated pages stay under your docs namespace:123456789101112131415161718{ "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" } ] } }
/docs/* namespace, leaving your app's root free for product routes.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.entry to the holocron plugin pointing to your Spiceflow server file:123456789// vite.config.ts import { defineConfig } from 'vite' import { holocron } from '@holocron.so/vite' export default defineConfig({ plugins: [ holocron({ entry: './src/server.tsx' }), ], })
.use():1234567891011121314151617181920212223242526// 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)
docs.json navigation. Your own routes take priority because they're registered first..use() before .use(holocronApp) runs on every request, including doc pages. This is useful for auth checks, analytics headers, or request logging.123456export const app = new Spiceflow() .use(async ({ request }, next) => { console.log(request.method, request.url) return next() }) .use(holocronApp)
@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.holocron(), importing your server file fails at module load:1Error: Cannot find package 'virtual:holocron-config'
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.123456789101112// 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/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.holocron() plugin detects an already-installed React, Spiceflow, Tailwind, or Cloudflare plugin and skips adding its own duplicate, so listing them yourself is safe.1234567891011121314151617181920// 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' }), ], })
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.@import 'tailwindcss' in your own CSS files. A second import creates duplicate style layers that break layout, spacing, and cascade order.@reference instead of @import. It gives your CSS access to Tailwind's theme variables, @apply, and @variant without emitting duplicate styles:12345678910111213/* src/globals.css */ @reference "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); :root { --primary: #e11d48; --background: #fafafa; @variant dark { --background: #0a0a0a; --primary: #fb7185; } }
@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.1@custom-variant dark (&:where(.dark, .dark *));
@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>.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:123456789101112131415'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> }
<html> shell, read the same cookie on the server and set the initial class before the page paints:1234567891011function 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> ))
global.css or style.css at the project root, import it normally in your server entry:1import './globals.css'
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.12345678910111213141516171819202122// 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) }, }