spfn (CLI)
spfn scaffolds and runs a Hono-based backend that lives inside a Next.js project.
It creates the server structure, runs the dev/build/start lifecycle, manages the
database (Drizzle Kit), generates the RPC route map, and validates environment variables.
Beta: install with the
@betatag (spfn@beta). The binary isspfn.
Install
No global install needed — run through your package manager's dlx/npx:
npx spfn@beta <command>
pnpm dlx spfn@beta <command>
Or add it as a project dependency (spfn init/spfn create do this for you), then
call it via pnpm spfn <command> / npm run spfn:<script>.
Requirements: Node.js 18.18+, Next.js 15+ (App Router, src/ dir), PostgreSQL (Redis optional).
Usage
# New project (runs create-next-app + spfn init)
npx spfn@beta create my-app
cd my-app
docker compose up -d # Postgres + Redis
# .env.local & .env.server are generated — put server secrets in .env.server
pnpm spfn:dev # Next.js :3790 + SPFN API :8790
# Add SPFN to an existing Next.js project
npx spfn@beta init
The package manager is auto-detected (pnpm > yarn > bun > npm) from lockfiles; override
with --pm. In a pnpm workspace, create installs from the workspace root.
Commands
Registered top-level commands: create, init, add, dev, build, start,
codegen, db, env, key, setup.
spfn create <name>
Runs create-next-app with SPFN-recommended flags (TypeScript, App Router, src/,
Tailwind, import alias @/*, no ESLint), sets up SVGR icons, then runs init.
| Option | Description |
|---|---|
--pm <manager> |
Force package manager: npm | pnpm | yarn | bun |
--shadcn |
Also run shadcn init |
--skip-install |
Skip dependency install |
--skip-git |
Skip git init |
-y, --yes |
Skip prompts, use defaults |
spfn init
Adds SPFN to an existing Next.js project: copies the server templates, wires the RPC
proxy route, Docker files, deploy + codegen config, updates package.json scripts/deps,
and installs. With auth enabled it also adds the /_auth/:path* → SPFN API rewrite to
next.config (OAuth callbacks return to the app origin; merged manually if a rewrites()
already exists). See Scaffold structure for what lands on disk.
| Option | Description |
|---|---|
-y, --yes |
Skip prompts, use defaults |
spfn add <package>
Installs an SPFN ecosystem package and applies its pre-built migrations. The package
name must be scoped (contain /).
pnpm spfn add @spfn/cms
pnpm spfn add @mycompany/spfn-analytics
How it works: if not already present it installs the package, then reads the package's
spfn field in its package.json (migrations, setupMessage) and applies any
function migrations to DATABASE_URL. If DATABASE_URL is unset, migration is skipped
with a hint to run spfn db push later. Works with published and workspace packages.
spfn dev
Starts the SPFN server + Next.js (and a codegen watcher). The server must report ready
(via a .spfn/server-ready signal file) before Next.js launches. Runs through tsx,
no pre-build needed.
| Option | Description | Default |
|---|---|---|
--server-only |
Run only the SPFN/Hono server (also auto-selected if Next.js isn't a dependency) | off |
--watch |
Restart the server on src/server changes (chokidar) |
off |
-p, --port <port> |
Server port | from server.config.ts / env (4000 in server-only fallback) |
-H, --host <host> |
Server host | localhost |
--routes <path> |
Routes directory path | server default |
Note: hot reload is off by default — pass --watch to restart on file changes.
spfn build
Runs codegen, builds Next.js (via the project's build script), and compiles
src/server/**/*.ts → .spfn/server with tsup. Also writes .spfn/prod-server.mjs
(the production entry consumed by spfn start).
| Option | Description |
|---|---|
--server-only |
Build only the SPFN server (skip Next.js) |
--next-only |
Build only Next.js (skip the SPFN server) |
--turbo |
Use Turbopack for the Next.js build |
spfn start
Starts the production servers from build output. Requires spfn build first — it errors
if .spfn/server, .spfn/prod-server.mjs, or .next are missing.
| Option | Description | Default |
|---|---|---|
--server-only |
Run only the SPFN server | off |
--next-only |
Run only Next.js | off |
-p, --port <port> |
SPFN server port (sets SPFN_PORT) |
8790 |
-h, --host <host> |
SPFN server host (sets SPFN_HOST) |
0.0.0.0 |
Next.js is started on 0.0.0.0:3790. Both run together via concurrently --kill-others.
spfn codegen
Manages code generators driven by .spfnrc.ts. The default generator is
@spfn/core:route-map, which emits src/generated/route-map.ts from src/server/router.ts
so the RPC proxy can resolve routes without importing server code. Generators also run
automatically during spfn dev and spfn build.
| Subcommand | Description |
|---|---|
codegen init |
Create .spfnrc.ts (--with-example shows custom-generator usage) |
codegen list (ls) |
List configured generators and their watch patterns |
codegen run |
Run all generators once (no watch) |
package.json exposes this as the codegen script (spfn codegen run).
To add a custom generator, implement the Generator interface from @spfn/core/codegen
and reference it in .spfnrc.ts:
// .spfnrc.ts
import { defineConfig, defineGenerator } from '@spfn/core/codegen';
export default defineConfig({
generators: [
defineGenerator({
name: '@spfn/core:route-map',
routerPath: './src/server/router.ts',
outputPath: './src/generated/route-map.ts',
}),
],
});
spfn db
Wraps Drizzle Kit with auto-generated config. Most commands read DATABASE_URL from the
loaded .env chain.
| Subcommand | Description |
|---|---|
db generate (g) |
Generate migrations from schema changes (timestamp-prefixed) |
db push |
Apply schema to DB. Safe by default; destructive changes need confirmation. --force applies destructive changes, --dry-run previews |
db migrate (m) |
Run pending migrations. --with-backup snapshots first |
db studio |
Open Drizzle Studio. -p, --port (auto-finds a free port) |
db check |
Verify the database connection |
db drop |
Drop all tables — destructive, double-prompts (see Pitfalls) |
db backup |
Create a backup (`-f sql |
db restore [file] |
Restore from a backup (--drop, -s, --data-only, --schema-only, -v) |
db backup:list |
List backups |
db backup:clean |
Prune backups (-k, --keep <n>, -o, --older-than <days>) |
db reindex |
Convert sequential migration prefixes to timestamps (--dry-run) |
db pushis for development. For production, usedb generate+db migrateto keep migration history.
spfn env
Schema-driven environment variable tooling (schema comes from a package's envSchema,
default @spfn/core). Routes vars to the right file: NEXT_PUBLIC_* → .env/.env.local,
server vars → .env.server.
| Subcommand | Description |
|---|---|
env list |
List vars from the schema (-g groups by target file) |
env stats |
Show variable statistics |
env search <query> |
Search vars by key or description |
env init |
Generate .env template files (-e <env> for per-env, -f to overwrite) |
env check |
Check .env files against the schema (-e <env> for a full env chain) |
env validate |
Validate process.env against the schema — for CI/CD (-e <env>, -s strict) |
All accept -p, --package <pkg> (env validate uses -p, --packages <pkgs...>).
spfn key [preset]
Generate cryptographically random secrets (base64url, 256-bit default).
spfn key # generic 256-bit secret
spfn key auth-encryption -c # preset key, copy to clipboard
spfn key --list # list presets
spfn key gen -b 64 # raw value only, no metadata (alias of `key generate`)
Presets: auth-encryption, nextauth-secret, jwt-secret, session-secret, api-key.
Options: -l, --list, -b, --bytes <n> (1–128), -e, --env <name>, -c, --copy.
The command prints the value to stdout for you to paste into an env file — it does not
write any file.
spfn secret
Unified secret management: local secrets live in the OS keychain, deployed secrets in
encrypted SOPS files. The runtime never sees a reference — spfn dev injects local
values into the server process and GitOps injects them in production, so the app always
reads plain process.env.
| Subcommand | Description |
|---|---|
secret set [key] |
Store a value (masked prompt). --env local → keychain; other envs → SOPS |
secret list |
List declared secrets and their status per env (never prints values) |
secret generate [key] |
Mint values for schema secrets with a generate strategy (-a/--all) |
secret rotate [key] |
Rotate values; external secrets are flagged for manual reissue (-a/--all) |
secret keygen |
Generate an age key pair for the SOPS no-cloud backend |
secret recipients <add|remove|list> [age1…] |
Manage .sops.yaml recipients + re-encrypt |
secret check |
Static lint — flag plaintext secret leaks |
Options: -e, --env <env> (local default; also development/staging/production),
-p, --package <pkg> (schema source, default @spfn/core).
Local (keychain). spfn secret set DB_URL stores the value in the OS keychain
(macOS security, Windows Credential Manager via optional @napi-rs/keyring, Linux
libsecret) and writes a secret:keychain:spfn_DB_URL reference into .env.server. The
reference is not sensitive; the real value never lands in the repo. spfn dev resolves
and injects it. Note: injection happens only when the server is started via spfn dev —
running the app another way (a bare node, tests) would see the raw reference, so use
spfn dev locally (a runtime resolver for other runners is planned).
Deployed (SOPS). spfn secret set DB_URL --env production writes the value into
secrets/production.enc.json, encrypted by SOPS. The backend (age / GCP KMS / AWS KMS)
is chosen by .sops.yaml creation rules — KMS needs no local key file (IAM + cloud
auth), age is the no-cloud fallback (secret keygen + secret recipients add). Commit
the encrypted file; your GitOps step decrypts it into env at deploy time. sops/age
are needed only for the deployed envs, never for local keychain use.
Schema-driven: a secret declared with envSecret({ generate: 'base64url32' }) can be
minted/rotated automatically (secret generate/rotate); one without generate is an
external value you paste in (secret set).
spfn setup icons
Install and configure SVGR for SVG-as-component imports (Next.js only).
Scaffold structure
spfn init (and create, which calls it) produces:
src/
app/api/rpc/[routeName]/route.ts # RPC proxy — re-exports { GET, POST } from @spfn/core/nextjs/server
generated/route-map.ts # generated by codegen (run `spfn codegen run` if missing)
lib/
api-client.ts # createApi<AppRouter>() — the type-safe client
server/
router.ts # defineRouter({ ...routes }) → export type AppRouter
server.config.ts # defineServerConfig().port(8790).host('0.0.0.0').routes(appRouter)
config/env.config.ts # environment schema
entities/ # Drizzle tables (example.entity.ts, config.ts)
repositories/ # BaseRepository subclasses (example.repository.ts)
routes/ # route DSL handlers: root.ts, health.ts, examples.ts
tsconfig.json, tsup.config.ts
.spfnrc.ts # codegen config (route-map generator)
spfn.config.js # deployment config (subdomain/region/domains) — committed
docker-compose.yml # Postgres + Redis (dev)
docker-compose.production.yml
Dockerfile, .dockerignore
next.config.ts # patched when auth is enabled: /_auth/:path* rewrite → SPFN API
.env.example # committed reference — every key, placeholder values
.env.local # generated, gitignored (Next.js-facing URLs)
.env.server # generated, gitignored (server secrets: DB, cache)
init also patches package.json (scripts: spfn:dev, spfn:server, spfn:next,
spfn:build, spfn:start, codegen; deps: @spfn/core, spfn, drizzle-orm,
@sinclair/typebox, concurrently, etc.), excludes src/server from the root
tsconfig.json (Vercel compat), and adds .spfn/, .env.local, .env.server to
.gitignore.
Route DSL (the current architecture)
Routes are defined with the route builder and collected by defineRouter. There is no
separate "contract" layer — the router's type is the contract; the client infers from it.
// src/server/routes/examples.ts
import { route } from '@spfn/core/route';
import { Type } from '@sinclair/typebox';
export const getExample = route.get('/examples/:id')
.input({ params: Type.Object({ id: Type.String() }) })
.handler(async (c) =>
{
const { params } = await c.data();
return { id: params.id };
});
// src/server/router.ts
import { defineRouter } from '@spfn/core/route';
import { getExample } from './routes/examples';
export const appRouter = defineRouter({ getExample });
export type AppRouter = typeof appRouter;
// src/lib/api-client.ts
import { createApi } from '@spfn/core/nextjs';
import type { AppRouter } from '@/server/router';
export const api = createApi<AppRouter>();
const example = await api.getExample.call({ params: { id: '123' } });
Client calls go through the Next.js RPC proxy (/api/rpc/[routeName]), which forwards to
the SPFN API with cookie forwarding and interceptors, resolving routes via the generated
route-map.ts.
Deployment
spfn build then spfn start, or use the generated Docker files.
# Build + run locally
pnpm spfn:build
pnpm spfn:start # Next.js :3790 + SPFN API :8790
# Docker (single image runs both)
docker compose -f docker-compose.production.yml up --build -d
The Dockerfile (node:22-alpine) installs with pnpm --frozen-lockfile, runs
pnpm run spfn:build, prunes dev deps, exposes 3790/8790, health-checks
http://localhost:8790/health, and starts via pnpm run spfn:start.
Run migrations against the target DB before/with deploy:
docker exec <container> npx spfn db migrate
spfn.config.js (committed) configures the managed *.spfn.app deployment: subdomain,
region (us default, kr, …), customDomains, and non-secret env. Its SpfnConfig
type ships from spfn (@type {import('spfn').SpfnConfig}).
Pitfalls
.env.serveris gitignored and server-only. Put DB/secret values there, not in.env(committed) and not in.env.local(that's Next.js's local file). There is no.env.server.local.spfn initgenerates.env.server; put DB/secret values there. Load order is the standard dotenv chain ending with.env.server.- Never commit secrets in
spfn.config.js. It's checked into Git; itsenvblock is for non-sensitive values only. Use CI/CD secret management for credentials. spfn devdoes not hot-reload by default. Add--watchto restart onsrc/serverchanges.spfn startneeds a priorspfn build. It hard-fails without.spfn/server,.spfn/prod-server.mjs, and.next.- Destructive DB commands are guarded.
db dropdouble-confirms (and verifies the target);db pushapplies additive changes but withholds destructive ones unless--force/confirmed. Preferdb generate+db migratefor production;db pushis dev-only. spfn addrequires a scoped package name (must contain/) and only applies migrations whenDATABASE_URLis set — otherwise it skips with a hint.- Package manager is auto-detected from lockfiles. If detection is wrong (e.g. mixed
lockfiles), pass
--pmtocreate. In a pnpm workspace,createinstalls from the workspace root, not the new project dir. - Regenerate the route map after route changes outside dev. If
src/generated/route-map.tsis missing or stale, runspfn codegen run— the RPC proxy depends on it.
Related
@spfn/core— server, route DSL, codegen, db, client runtime.- Project root README — framework overview and getting started.