Typebase.
CLI

typebase-io-cli generate-server

Generate server files locally for custom hosting or inspection.

Last updated on

The generate-server command builds your server files locally instead of deploying them. This is useful when you want to:

  • Self-host your server on your own infrastructure
  • Inspect the generated code to understand what Typebase produces
  • Customize the deployment process beyond what npx typebase-io-cli deploy offers

To run your server on your own machine, use start instead. It builds, installs, applies your schema changes, and runs the server from one command, rebuilding and restarting on every change. generate-server produces an artifact and stops there: it installs nothing, never touches a database, and the server it writes has to be started and restarted by you.

Reach for generate-server when the generated files themselves are the point — reading them, hosting them, or building on them — or when you need an adapter or output format other than the ones a local run picks for itself.

Quick start

npx typebase-io-cli generate-server

This generates a complete, runnable server in the typebase/_server/ directory (by default).

It regenerates _generated/ first (the same work codegen does), then type-checks your project against those fresh types, then builds.

Embedded servers

By default generate-server writes a standalone server: one that owns a process, creates its own HTTP listener, and starts it. --embedded writes an embedded server instead — the same actions, database, and auth, emitted without a bootstrap, for an application that already owns its process to mount:

npx typebase-io-cli generate-server --embedded

This writes source and a typebase-server.json marker into typebase/_handler/ (by default), then prints what your application has to provide before it can mount them.

Set { "server": { "embedded": true } } in typebase.json to make this the default. Set it to false or remove it to generate a standalone server again; --embedded can override a configured false.

--port conflicts with --embedded, and is also rejected when embedded generation is enabled in configuration: an embedded server never listens, so nothing would use it. A port stored in typebase.json is ignored rather than rejected, so one configuration file can still serve every command. start always builds a standalone server, whatever server.embedded says.

What an embedded build emits

_handler/
  src/
    server.ts            # Router, RPC handler, auth wiring, and the mount
    _generated/
      server.ts          # Action builder and middleware setup
    actions/             # Your action files (copied and processed)
    db/                  # Your schema, relations and migrations (if applicable)
    auth.ts              # Your auth config (if applicable)
    env.ts               # Validated environment variables (if applicable)
    publisher.ts         # Publisher runtime config (if applicable)
    storage.ts           # Storage runtime config (if applicable)
  typebase-server.json   # Adapter, mode, CLI version, dependencies, env keys

src/server.ts exports the same three names for every adapter, so switching adapters doesn't touch your integration code:

ExportWhat it is
typebaseHandlerThe value you mount. Its shape follows the adapter: a Fastify plugin, a Hono app, a Connect-style Node middleware, or a fetch handler for Bun, Deno and Cloudflare.
routerThe router the handler serves, so you can build on it beyond what the mount provides.
authThe better-auth instance, when your project has an auth.ts, so your own routes can read the session.

There is no index.ts, and no package.json, tsconfig.json, .env, or package-manager config file: an embedded server is a fragment of somebody else's application rather than an application of its own. TypeScript output uses .js import specifiers for its own relative imports, so it compiles under an ordinary application tsconfig.json under both nodenext and bundler resolution.

--output esm and --output cjs are available too, but pick the one your application already uses. A standalone server declares its own "type" in the package.json it ships; an embedded server has no manifest, so its .js files are interpreted using your application's "type" — esm output in a "type": "commonjs" host, or cjs output in a "type": "module" host, fails at import.

What your application provides

Three things an embedded server deliberately leaves to its host:

  • Dependencies. It ships no dependency tree of its own. A nested package.json inside your application would mean a second copy of drizzle-orm, better-auth, or fastify loaded in one process, where an instanceof check across the two copies fails and the error surfaces nowhere near its cause. The build tells you what to install in your own package instead.
  • Environment. It never loads a .env file, and no .env is written into the output directory, so mounting it cannot change environment your application already configured — and a generated fragment inside your source tree never becomes a place secrets accumulate. src/env.ts is still emitted and still validates on import, so the values have to be in process.env before your host imports server.ts.
  • Cross-origin policy. The generated code sets no CORS headers. Same-origin browser requests need none. For cross-origin browser clients, configure your host's CORS middleware before the Typebase routes so it handles preflight requests and response headers for both actions and auth. The generator omits its own CORS plugin rather than install a second policy alongside yours.

The first build reports all three after its success message:

Embedded server requirements

Install missing or incompatible dependencies in the host package:
npm install '@orpc/server@1.14.3' 'better-auth@1.6.11' 'drizzle-orm@1.0.0-beta.22'
npm install -D 'typescript@5.9.3'

Required Environment keys: DATABASE_URL, BETTER_AUTH_SECRET
Load these in your host before importing the embedded server.

Cross-origin headers are your host application's responsibility. Configure its CORS policy for your clients.

Mount from your project root (adjust the import for your entry point):
import { typebaseHandler } from "./typebase/_handler/src/server.js";

// In your existing Express application:
app.use(typebaseHandler);

Dependency and environment details: typebase/_handler/typebase-server.json

The install command uses the package manager your project uses, and lists only what your package.json doesn't already declare. A dependency you have at a version the generated server doesn't support is warned about and included. Everything the report says is also in typebase-server.json, so an install step can read it instead of you.

The list is what a standalone build would install, plus anything your own code inside typebase/ imports that the host doesn't declare. In a workspace that second group matters: when your typebase/ directory belongs to a different package from the one mounting the server, the imports your actions rely on are declared somewhere the host can't resolve them from.

The report is printed on the first build only. With --watch, rebuilds stay quiet so a long session stays readable.

Mounting

One line per adapter, against the default typebase/_handler/ output. See HTTP adapters for choosing one.

node — a Connect-style middleware
import express from 'express';

import { typebaseHandler } from './typebase/_handler/src/server.js';

const app = express();

app.use(typebaseHandler);

It calls next() when the request wasn't for Typebase, so your other routes still run. Called without a next — createServer(typebaseHandler) — it answers 404 instead.

fastify — a plugin
import Fastify from 'fastify';

import { typebaseHandler } from './typebase/_handler/src/server.js';

const app = Fastify();

await app.register(typebaseHandler);

Typebase's catch-all content type parser is registered inside the plugin, and Fastify scopes parsers to the plugin they belong to, so your own routes keep the body parsing they had.

hono — a sub-application
import { Hono } from 'hono';

import { typebaseHandler } from './typebase/_handler/src/server.js';

const app = new Hono();

app.route('/', typebaseHandler);
bun — a fetch handler
import { typebaseHandler } from './typebase/_handler/src/server.js';

Bun.serve({ fetch: typebaseHandler });
deno — a fetch handler
import { typebaseHandler } from './typebase/_handler/src/server.js';

Deno.serve(typebaseHandler);
cloudflare — a Worker fetch handler
import { typebaseHandler } from './typebase/_handler/src/server.js';

export default { fetch: typebaseHandler };

In a CommonJS application built with --output cjs, the import becomes const { typebaseHandler } = require('./typebase/_handler/src/server.js') and the Cloudflare mount becomes module.exports = { fetch: typebaseHandler }.

Serving paths

An embedded server is mounted wherever you like, so the paths it serves are configurable, and independently of each other:

FlagServesDefault
--actions-path <path>Your actions/rpc
--auth-path <path>Auth/api/auth
--storage-path <path>Local storage files, with --local-storage only/storage
npx typebase-io-cli generate-server --embedded --actions-path /typebase/rpc --auth-path /typebase/auth

Both are also server.actionsPath and server.authPath in typebase.json. The defaults are the paths a standalone server serves, so a build that sets neither is unchanged.

Both options are embedded-only. Passing either with a standalone build is an error, because a standalone server always serves the paths it was built with and nothing would use the value. A value inherited from typebase.json is ignored instead, for the same reason a configured port is.

Generation refuses when the auth path would swallow the actions path — --auth-path /typebase together with --actions-path /typebase/rpc, for instance. Auth is matched first, so every action request would be answered by auth and never reach your actions. The storage path is checked the same way: it can't sit under the auth path, and it can't contain the actions path. Keeping the two paths independent, rather than deriving both from one prefix, is what lets you move either one without the other picking up a duplicated segment.

A custom actions path changes the URL your client points at. See the server URL. The auth path is also pushed into the generated auth config as better-auth's basePath; see the auth path for what happens when your own auth.ts already sets one.

Output formats

You can choose between three output formats:

TypeScript (default)

npx typebase-io-cli generate-server --output ts

Generates TypeScript source files. You'll need to compile them yourself before running.

ESM (ECMAScript Modules)

npx typebase-io-cli generate-server --output esm

Generates JavaScript files using import/export syntax. Ready to run with Node.js (with "type": "module") or Deno.

CommonJS

npx typebase-io-cli generate-server --output cjs

Generates JavaScript files using require/module.exports syntax. Ready to run with Node.js without "type": "module".

HTTP adapters

Typebase supports multiple HTTP adapters. The adapter determines how your server handles incoming HTTP requests:

AdapterDescriptionBest for
nodeNode.js built-in http.createServerSelf-hosting with Node.js (default)
bunBun's built-in HTTP serverSelf-hosting with Bun
honoHono frameworkVercel, general-purpose
fastifyFastify frameworkHigh-performance Node.js
denoDeno's built-in Deno.serveDeno Deploy
cloudflareCloudflare Workers fetch handlerCloudflare Workers
npx typebase-io-cli generate-server --adapter hono
npx typebase-io-cli generate-server --adapter node
npx typebase-io-cli generate-server --adapter fastify

Options

FlagDescriptionDefault
--output <type>Output format: ts, esm, or cjsts
--adapter <adapter>HTTP adapter: node, bun, hono, fastify, deno, cloudflarenode
--embeddedGenerate source for your existing application to mountfalse
--out-dir <path>Output directory for generated files_server, or _handler when embedded
--actions-path <path>Path an embedded server serves your actions at (embedded only)/rpc
--auth-path <path>Path an embedded server serves auth at (embedded only)/api/auth
--local-storageKeep a Vercel or Cloudflare storage's files on disk and serve them from the serverfalse
--storage-path <path>Path an embedded server serves local storage at (embedded and --local-storage only)/storage
--watchRebuild on every change inside your typebase/ directoryfalse
--command <command>Command to run in the generated server after it is generatednone
--port <number>Port the generated server listens on (standalone only)8080

Default configuration

You can set defaults in your typebase.json so you don't have to pass flags every time:

typebase.json
{
  "server": {
    "output": "esm",
    "adapter": "hono",
    "outDir": "_server",
    "port": 8080
  }
}

See the Configuration page for all available options.

What gets generated

A standalone server directory contains:

_server/
  src/
    index.ts           # Bootstrap: loads env and starts listening
    server.ts          # Router, RPC handler, auth wiring, and the mount
    _generated/
      server.ts        # Action builder and middleware setup
    actions/           # Your action files (copied and processed)
    db/                # Your schema, relations and migrations (if applicable)
    auth.ts            # Your auth config (if applicable)
    env.ts             # Validated environment variables (if applicable)
    publisher.ts       # Publisher runtime config (if applicable)
    storage.ts         # Storage runtime config (if applicable)
  .local-storage/      # Local storage files (with --local-storage, once the server has stored one)
  package.json         # Dependencies for the server
  tsconfig.json        # TypeScript config
  typebase-server.json # Adapter, mode, CLI version, dependencies, env keys
  pnpm-workspace.yaml  # Package-manager config (name depends on your package manager)

src/server.ts builds everything a host needs to serve requests and src/index.ts starts it listening. Both modes generate the first; only a standalone build generates the second, which is why an embedded server can't drift from the runnable one, the runnable one is built on top of it.

A package-manager config file sits next to package.json when your package manager needs one: .yarnrc.yml for yarn berry, bunfig.toml for bun, and pnpm-workspace.yaml for pnpm. The pnpm one makes the generated server a workspace root in its own right. Without it, installing inside a generated server that sits under a pnpm workspace walks up to your workspace root, installs your own projects instead, and leaves the generated server with no dependencies while still reporting success. It also carries allowBuilds, which is where pnpm 11 reads the permission for esbuild to run its install script; pnpm 10 reads that from the pnpm field in the generated package.json instead. npm, yarn classic, and a package manager we cannot identify get no extra file.

src/publisher.ts is emitted when the project has a publisher.ts; it creates the configured provider with the runtime resources it needs. For the db provider, that is the generated database client.

.env in a standalone server is filled in from your project-root .env, with the keys the server validates: DATABASE_URL, BETTER_AUTH_SECRET, your storage credentials, and whatever you declared in env.ts. DATABASE_URL and the storage keys come from their _DEV versions when there are any, so a server you run locally reaches the dev branch and the dev buckets. Keys already in the file are never overwritten, and nothing else from your project .env (provider tokens especially) is copied. This is a generate-server convenience only; deploy syncs variables to the provider instead and never puts a .env in the bundle. An embedded server gets no .env either: your application loads the environment it runs in.

src/db/migrations/ is copied across when your project uses migrations, for every output format, and the drizzle config written next to it points its out at them. The generated server is self-contained: you can apply the migrations it shipped with using standard drizzle tooling. The server never reads them itself — migrations are applied by the CLI, from your machine or from CI. A project in push mode gets no migrations folder.

src/env.ts is emitted whenever your project has an env.ts, a db/schema.ts, an auth.ts, or a storage.ts that needs credentials — anything that means the server has variables to validate. In a standalone server, dotenv is added to package.json and dotenv/config imported by index.ts, only when that env module exists and the adapter is not cloudflare, which reads its values from Worker bindings rather than process.env. A server with nothing to read gets neither. An embedded server never loads environment files at all, since it has no bootstrap and mutating its host's environment is not its business.

Storage

src/storage.ts is emitted when the project has a storage.ts. It creates your declared storage with what the provider needs to run, and files-sdk is added to the server's dependencies.

By default that is your real provider. A vercel or cloudflare storage reads its credentials from the environment, under the same names a deployed server uses, so a self-hosted server is configured exactly like a deployed one. A filesystem storage needs nothing and keeps its files in its own root.

--local-storage

--local-storage swaps a vercel or cloudflare provider for local storage, the same one a local run uses: files on disk, served and signed by the generated server itself. It needs no credentials, so it reads no storage variables.

npx typebase-io-cli generate-server --local-storage

It is a flag rather than a fallback on purpose. A server generated without it that is missing a storage variable fails at boot, instead of quietly writing production uploads to a disk that may not survive the next deploy.

Files are kept in .local-storage, resolved against the directory the server runs from. For a standalone server started from typebase/_server/, that's typebase/_server/.local-storage/, which regeneration keeps alongside .env and node_modules. The URL signing secret is created there on first use.

A standalone server serves the files at /storage on its own port. An embedded server serves them at --storage-path, or server.storagePath in typebase.json, and builds its URLs as paths relative to your application's origin. The route is part of typebaseHandler, like auth, so mounting is unchanged. A server built without --local-storage has no storage route at all.

--local-storage does nothing for a project without a storage.ts, or whose storage.ts declares filesystem, and says so. It can't be combined with --adapter cloudflare, since a Worker has no disk to keep files on, and --storage-path without --local-storage is an error, because nothing would serve the path.

Watch mode

generate-server is a snapshot, so every edit normally means running it again. --watch does that for you:

npx typebase-io-cli generate-server --watch

It builds once, then rebuilds whenever anything inside your typebase/ directory changes. Press x or Ctrl+C to stop.

The first build reports each step. Every rebuild after that is a single Regenerating... line, so a long session stays readable. Errors are still printed in full.

Changes to _generated/ and to the output directory are ignored. Generation writes to both, so watching them would make every build trigger the next one.

If you save again while a build is running, that build is cancelled and a new one starts, so the output always reflects your latest sources rather than a half-finished older run. A build is only cancellable up to the point where it starts replacing the output directory; the final copy always runs to completion, so the directory is never left half written.

A failed build, a type error most often, does not stop the watcher. The error is printed and the next change triggers another attempt.

Watch mode rebuilds the files in the output directory. A server you already started from them keeps running the old code until you restart it. See Work locally.

Watch mode is a long-running command. If you drive the CLI from a script or an agent, run it in the background or with a timeout, and stop it with SIGINT rather than x when the output is piped.

Running the server on every rebuild

--command runs a command inside the generated server directory once generation finishes. Combined with --watch, it restarts that command on every rebuild, so what is running is always the latest build:

npx typebase-io-cli generate-server --watch --command "pnpm start"

The examples on this page assume a standalone server, where that directory is a package with a start script. --command is unchanged for an embedded server, including the directory it runs in, but that directory holds no package.json, so a package script like pnpm start has nothing to run there. Pass something that doesn't depend on the output directory being a package, or pass nothing and let your own application's watcher pick up the regenerated source.

If what you want is a server running on your machine, start is the shorter path: it does the install for you, applies schema changes on every pass, keeps the build out of your project tree, and starts the server without a package script in between — which is also why it never prints the ELIFECYCLE line below.

The one-time setup is installing dependencies in the generated server, since the command runs there:

npx typebase-io-cli generate-server
cd typebase/_server
pnpm install

Then leave the watcher running. Editing anything in typebase/ rebuilds the server and restarts your command against it.

Regenerating replaces typebase/_server/, but node_modules and .env are kept — which is why installing once is enough, and why your local environment variables survive.

The command is run through a shell, so it behaves the way it does when typed. Stopping with x or Ctrl+C stops the command too, including the server your package script started rather than just the script itself.

A rebuild that fails leaves the running command alone, so a type error does not take your server down. A command that exits — because it crashed, or because it was never long-running — does not stop the watcher either; the next change runs it again.

Without --watch, the command runs once after generating and the CLI waits for it, still stopping it on x or Ctrl+C.

The command does not get the terminal's input. That keeps x and Ctrl+C working while it runs, so an interactive command is not what this flag is for. Its output goes straight to your terminal as usual.

ELIFECYCLE Command failed. after every reload

Expected, and not a failure. Stopping the command signals it, and pnpm reports any script that ends on a signal as a failed lifecycle — the same line you get from pressing Ctrl+C during a plain pnpm start. The rebuild and the restart both worked.

Pass --silent to keep pnpm quiet about it:

npx typebase-io-cli generate-server --watch --command "pnpm --silent start"

Running the server directly, without a package script in between, avoids it too:

npx typebase-io-cli generate-server --watch --command "node src/index.js"

Regeneration

Each run rebuilds the server from scratch and replaces the previous output directory — files whose source was deleted or renamed don't linger. Three exceptions survive regeneration because they belong to you, not the generator: .env, node_modules, and the .local-storage files of a server generated with --local-storage. Install dependencies and configure your env once; re-runs keep them.

If a generation fails, the previous output is left untouched, so a working server is never destroyed by a failed rebuild.

As a safety measure, the CLI only replaces a directory it recognizes as its own: one that is empty, doesn't exist yet, or carries the typebase-server.json marker a previous build wrote. Pointing --out-dir (or server.outDir) at any other non-empty directory aborts with an error instead of overwriting it. A directory generated before markers existed is still recognized by its @typebase-io/server package.json, so upgrading doesn't make your next build refuse to run.

That check matters most for an embedded server, which has no package.json to be recognized by and is often pointed at a directory inside your own source tree, where replacing the wrong one is expensive.

Running the generated server

This section is about a standalone server. An embedded server has no entry point and no dependencies of its own: your application starts it by mounting it.

After generating the server, you can run it like any Node.js project:

cd typebase/_server

# Install dependencies
npm install

# Run (if TypeScript output)
npx tsx src/index.ts

# Run (if ESM output)
node src/index.js

# Run (if CJS output)
node src/index.js

The server starts on port 8080 by default. Override it with --port or the server.port field in typebase.json.

On this page