Deployment
Furin supports two deployment strategies: a Bun build (live server with SSR/ISR support) and a static export (pre-rendered HTML files, no server required).
Static Export — GitHub Pages
The static export pre-renders all SSG routes to dist/path/index.html files and generates a 404.html SPA shell so that client-side navigation keeps working on GitHub Pages.
Configuration
Add a static block to furin.config.ts:
import { defineConfig } from "@teyik0/furin/config";
export default defineConfig({
plugins: [...],
static: {
basePath: "/my-repo", // GitHub repository name, no trailing slash
outDir: "dist", // output directory (default: "dist")
onSSR: "error", // "error" (default) | "skip"
},
});
| Option | Type | Default | Description |
|---|---|---|---|
basePath | string | "" | Path prefix for GitHub Pages (e.g. "/furin" when the site lives at user.github.io/furin/) |
outDir | string | "dist" | Output directory |
onSSR | "error" | "skip" | "error" | Behavior when an SSR route is encountered: hard error or warn + silent skip |
Build Command
{
"scripts": {
"build:static": "furin build --target static"
}
}
Output Layout
dist/
├── index.html ← route "/"
├── blog/
│ └── hello-world/
│ └── index.html ← route "/blog/hello-world"
├── 404.html ← SPA shell (GitHub Pages fallback)
└── _client/
├── _hydrate.js
└── _hydrate.css
SSR / ISR Routes
The static export does not support SSR or ISR routes — they require a live server.
onSSR: "error"(default) — the build fails and lists all non-exportable routes. Recommended to prevent silent production gaps.onSSR: "skip"— SSR/ISR routes are skipped with a warning. Useful when some pages are served through a separate mechanism.
# Example error output with onSSR: "error"
✗ Static export failed: the following routes cannot be statically exported:
- /dashboard (mode=ssr)
- /feed (mode=isr)
Set onSSR: "skip" in furin.config.ts to skip them instead.
Deploying to GitHub Pages
Add a workflow file at .github/workflows/deploy.yml:
name: Deploy to GitHub Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- run: bun install
- run: bun run build:static
- uses: actions/upload-pages-artifact@v3
with:
path: dist
- id: deploy
uses: actions/deploy-pages@v4
Tip: the generated
404.htmlis an empty SPA shell. GitHub Pages serves it automatically for any unknown URL, which lets the client-side router take over.
Bun Build (Live Server)
Use the Furin CLI through your package script or directly:
{
"scripts": {
"build": "bunx furin build --target bun"
}
}
Output Layout
The Bun build writes to .furin/build/bun:
.furin/build/bun/
├── client/
│ └── index.html
├── public/
├── server.js
└── manifest.json
Compiled Binaries
If you pass --compile server, Furin writes a server binary and keeps the built client assets on disk.
bunx furin build --target bun --compile server
If you pass --compile embed, Furin writes a self-contained server binary and removes the built client directory from the target output because the assets are embedded.
bunx furin build --target bun --compile embed
Production Runtime
For disk-based builds:
server.jsreads client assets fromclient/next to the server bundlepublic/is copied into.furin/build/bun/publicFURIN_CLIENT_DIRcan override the client asset directory at runtime
FURIN_CLIENT_DIR=/absolute/path/to/client bun ./.furin/build/bun/server.js
Multi-replica sync
Sync always requires an explicit runtime shared by furinSync(sync) and furin({ sync }). Production rejects process-local adapters, including SQLite :memory:. Configure one durable namespace shared by every replica. PostgreSQL is the recommended default because mutation completion and the change journal commit in one transaction.
For a durable single-host deployment, @teyik0/furin/sync/sqlite can share the application's bun:sqlite database. Enable WAL and a busy timeout, run migrateSqliteSync(database), and pass the same adapter instance to both plugins. Its host-local scope coordinates processes sharing one local file but does not support replicas on different hosts or a network filesystem. The task-manager example is the executable reference.
bun add @teyik0/furin
bunx --package @teyik0/furin furin-sync-postgres-migrate
Set FURIN_SYNC_POSTGRES_URL in the server environment before running the command. The idempotent schema bootstrap at @teyik0/furin/sync/postgres/migration.sql creates only the furin_sync schema and is safe to run through your existing migration system. It is not a versioned migration history and is never applied implicitly at application startup.
Construct the adapter on the server and pass the same runtime object to the mutation and page plugins. Keep the namespace stable across every replica:
import { SQL } from "bun"
import { Elysia } from "elysia"
import { furin, furinSync } from "@teyik0/furin"
import { postgresSyncAdapter } from "@teyik0/furin/sync/postgres"
const databaseUrl = process.env.FURIN_SYNC_POSTGRES_URL
if (databaseUrl === undefined) {
throw new Error("FURIN_SYNC_POSTGRES_URL is required")
}
const namespace = "task-manager"
const adapter = postgresSyncAdapter({
namespace,
sql: new SQL(databaseUrl),
})
const sync = { adapter, principal: ({ request }) => requireUser(request).id }
new Elysia()
.use(furinSync(sync))
.use(await furin({ pagesDir: "./src/pages", sync }))
.listen(3000)
For low-latency cross-replica wake-ups, import the Redis notifier subpath and use the same namespace. Create the Redis client once and replace the sync declaration above with:
import { RedisClient } from "bun"
import { redisSyncNotifier } from "@teyik0/furin/sync/redis"
const redisUrl = process.env.FURIN_SYNC_REDIS_URL
if (redisUrl === undefined) {
throw new Error("FURIN_SYNC_REDIS_URL is required")
}
const client = new RedisClient(redisUrl)
const sync = {
adapter,
notifier: redisSyncNotifier({ client, namespace }),
principal: ({ request }) => requireUser(request).id,
}
Redis is not a correctness dependency in this hybrid: clients catch up from PostgreSQL after a missed notification, and the SSE coordinator also polls the current cursor as a safety net.
Use a stable, application-specific namespace in every replica. Changing it creates a separate replay and change history. Keep database and Redis URLs in server-only environment variables; adapters are backend-only and never belong in route components or the browser entry.
Planned Targets
The config surface already names node, vercel, and cloudflare, but the current core build path throws for those targets. Treat them as planned, not available, until their builders land in packages/core/src/build/index.ts.
Public Environment Variables
Prefix client-visible variables with FURIN_PUBLIC_ and expose them through Bun:
[serve.static]
env = "FURIN_PUBLIC_*"
FURIN_PUBLIC_API_URL=https://api.example.com
Giscus In The Docs App
The docs app can render Giscus comments from GitHub Discussions by setting these public values:
FURIN_PUBLIC_GISCUS_REPO=teyik0/furin
FURIN_PUBLIC_GISCUS_REPO_ID=...
FURIN_PUBLIC_GISCUS_CATEGORY=General
FURIN_PUBLIC_GISCUS_CATEGORY_ID=...
That keeps comments out of your application database.