Superfunction

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 @beta tag (spfn@beta). The binary is spfn.

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 push is for development. For production, use db generate + db migrate to 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.server is 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 init generates .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; its env block is for non-sensitive values only. Use CI/CD secret management for credentials.
  • spfn dev does not hot-reload by default. Add --watch to restart on src/server changes.
  • spfn start needs a prior spfn build. It hard-fails without .spfn/server, .spfn/prod-server.mjs, and .next.
  • Destructive DB commands are guarded. db drop double-confirms (and verifies the target); db push applies additive changes but withholds destructive ones unless --force/confirmed. Prefer db generate + db migrate for production; db push is dev-only.
  • spfn add requires a scoped package name (must contain /) and only applies migrations when DATABASE_URL is set — otherwise it skips with a hint.
  • Package manager is auto-detected from lockfiles. If detection is wrong (e.g. mixed lockfiles), pass --pm to create. In a pnpm workspace, create installs from the workspace root, not the new project dir.
  • Regenerate the route map after route changes outside dev. If src/generated/route-map.ts is missing or stale, run spfn codegen run — the RPC proxy depends on it.

  • @spfn/core — server, route DSL, codegen, db, client runtime.
  • Project root README — framework overview and getting started.