Inline API

Build complete applications in a single file with inline config, providers, middleware, and frontend. No directory structure needed.

Overview

The Inline API lets you configure everything through tekir() options. No config/ directory and no start/ files, just one file. Good for prototyping, microservices, and small apps.

For larger apps, use the structured approach with separate config, start, and core directories.

Hello World

index.ts
import { tekir } from '@tekir/core'

const { router, start } = await tekir({
  config: { app: { port: 3000 } }
})

router.get('/', () => ({ message: 'Hello from tekir!' }))

start()

Run with tekir serve and visit http://localhost:3000.

tekir() Options

tekir() returns a TekirApp object. Destructure what you need:

const { router, server, service, config, logger, start, onStart, onShutdown } = await tekir({
  // App config: no config/ directory needed
  config: {
    app: { name: 'My App', port: 3000, env: 'development' },
    database: { ... },
    cache: { ... }
  },

  // DI providers
  providers: [DatabaseProvider, CacheProvider, AuthProvider],

  // Server-level middleware (every request)
  middleware: [cors({ origin: true })],

  // Router-level middleware (matched routes only)
  routerMiddleware: [bodyParser(), serverTiming()],

  // Inline route registration. Runs after providers boot, methods
  // are pre-bound so destructuring works.
  routes: ({ get, post }) => {
    get('/health', () => ({ ok: true }))
    post('/users', async ({ body }) => createUser(body))
  },

  // File-based loaders. Each one is opt-in. Without these set, tekir
  // does not scan the project for env/config/start files.
  envFile: 'env.ts',         // path to a single env-setup file
  configDir: 'config',       // directory of config/*.ts files
  startDir: 'start',         // directory with kernel.ts, routes.ts, boot.ts, commands.ts

  // Frontend integration
  frontend: { type: 'bun' }  // or 'vite' or 'next'
})
  • router: register routes
  • server: low-level server access (fallback, static routes)
  • service: typed lazy proxy to DI container
  • config: read config values
  • logger: structured logger
  • start(): start HTTP server
  • onStart() / onShutdown(): lifecycle hooks

Inline Config

Pass config directly. This replaces the config/ directory entirely:

const { router, service } = await tekir({
  config: {
    app: { name: 'Todo API', port: 3000, env: 'development' },
    database: {
      default: 'sqlite',
      connections: {
        sqlite: { driver: 'sqlite', connection: { path: ':memory:' } }
      }
    },
    cache: {
      default: 'memory',
      stores: { memory: { driver: 'memory', ttl: 3600 } }
    },
    cors: { origin: true },
    hash: { default: 'bcrypt', drivers: { bcrypt: { rounds: 10 } } }
  }
})

Providers

Providers register services in the DI container. Each provider creates a service accessible via service<T>():

import { tekir } from '@tekir/core'
import { DatabaseProvider } from '@tekir/db'
import { CacheProvider } from '@tekir/cache'
import { AuthProvider } from '@tekir/auth'
import { HashProvider } from '@tekir/hash'
import { EmitterProvider } from '@tekir/emitter'
import { CronProvider } from '@tekir/cron'

const { router, service } = await tekir({
  config: { ... },
  providers: [
    DatabaseProvider,   // → service<Database>('db')
    CacheProvider,      // → service<Cache>('cache')
    AuthProvider,       // → service<Auth>('auth')
    HashProvider,       // → service<Hash>('hash')
    EmitterProvider,    // → service<Emitter>('emitter')
    CronProvider       // → service<Cron>('cron')
  ]
})

Middleware

Two levels of middleware: server-level (every request) and router-level (matched routes only):

import { tekir } from '@tekir/core'
import { cors } from '@tekir/cors'
import { bodyParser } from '@tekir/bodyparser'
import { limiter } from '@tekir/limiter'
import { serverTiming } from '@tekir/core'

const { router } = await tekir({
  config: { ... },

  // Runs on EVERY request (even unmatched routes)
  middleware: [
    cors({ origin: true })
  ],

  // Runs only on MATCHED routes
  routerMiddleware: [
    bodyParser(),
    serverTiming()
  ]
})

// Per-route middleware
router
  .get('/api/health', () => ({ status: 'ok' }))
  .use(limiter({ max: 5, window: 60 }))

Inline Routes

For single-file apps you can register routes directly in thetekir() call. The callback runs after providers boot, so service() resolves to real instances inside it. Methods on the passed router are pre-bound, so destructuring ({ get, post }) works without losing this.

import { tekir, service } from '@tekir/core'
import type { Database } from '@tekir/db'

await tekir({
  config: { app: { port: 3000 } },
  providers: [DatabaseProvider],

  // The routes callback runs after providers boot, so service('db')
  // resolves to a real Database here. Destructure the methods you use:
  // they are pre-bound, so calling them detached works.
  routes: async ({ get, post }) => {
    const db = service<Database>('db')

    get('/users', async () => db.query('SELECT * FROM users'))

    post('/users', async ({ body }) => {
      await db.run('INSERT INTO users (name) VALUES (?)', [body.name])
      return { created: true }
    })
  },
})

This is purely optional sugar. You can still destructure router from the result and call router.get() outside. Use whichever reads better for the app you are writing.

Database

Full CRUD with SQLite in one file:

index.ts
import { tekir } from '@tekir/core'
import { DatabaseProvider } from '@tekir/db'
import type { Database } from '@tekir/db'

const { router, service } = await tekir({
  config: {
    app: { port: 3000 },
    database: {
      default: 'sqlite',
      connections: {
        sqlite: { driver: 'sqlite', connection: { path: './app.db' } }
      }
    }
  },
  providers: [DatabaseProvider]
})

const db = service<Database>('db')

// Create tables
await db.exec(`CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL
)`)

// CRUD routes
router.get('/api/users', async () => await db.query('SELECT * FROM users'))

router.post('/api/users', async ({ body }) => {
  await db.run('INSERT INTO users (name, email) VALUES (?, ?)', [body.name, body.email])
  return await db.queryOne('SELECT * FROM users ORDER BY id DESC LIMIT 1')
})

router.get('/api/users/:id', async ({ params }) =>
  await db.queryOne('SELECT * FROM users WHERE id = ?', [params.id])
)

router.delete('/api/users/:id', async ({ params }) => {
  await db.run('DELETE FROM users WHERE id = ?', [params.id])
  return { deleted: true }
})

Frontend Integration

Serve the frontend and API on the same port. Three options:

Security: Backend environment variables (DATABASE_URL, APP_KEY, etc.) are never exposed to the frontend. Bun native mode does not inline process.envinto the client bundle at all — expose values you want the browser to read through a regular route. Vite inlines only VITE_* vars and Next.js only NEXT_PUBLIC_*; everything else stays server-side.

Zero-config React/TS/CSS with HMR. Supports single executable. Put HTML in resources/. See the Bun Native page for multi-page apps, HMR, and embed-asset details.

const { router, service } = await tekir({
  config: { app: { port: 3000 } },
  providers: [DatabaseProvider],
  frontend: { type: 'bun' }   // HTML in resources/
})

Vite

Vite ecosystem (React, Vue, Svelte, Solid). Requires bun add @tekir/vite. Single executable supported. See the Vite page for vite.config.ts, plugins, and build options.

const { router, service } = await tekir({
  config: { app: { port: 3000 } },
  providers: [DatabaseProvider],
  frontend: { type: 'vite' },   // requires @tekir/vite
  // frontend: { type: 'vite', plugins: [react()] }
})

Next.js

SSR with the pages or app router. Requires bun add @tekir/next. See the Next.js page for Turbopack, project layout, and option details.

const { router, service } = await tekir({
  config: { app: { port: 3000 } },
  providers: [DatabaseProvider],
  frontend: { type: 'next' },   // requires @tekir/next
  // frontend: { type: 'next', turbopack: true }
})

Frontend Environment Variables

Secrets (DATABASE_URL, APP_KEY, JWT secrets) live in .env and stay on the server. In Bun native mode the HTML bundler does not substitute process.env references, so the client never sees any of them by default. For values the browser legitimately needs, expose a route and fetch it:

.env
# .env
DATABASE_URL=postgres://localhost/mydb   # Backend only, never leaves the server
APP_KEY=...                              # tekir generate:key

PUBLIC_API_URL=https://api.example.com   # Expose via a route, see below
PUBLIC_APP_NAME=My App
index.ts
// index.ts (server) — expose the values you want the browser to read.
router.get('/api/config', () => ({
  apiUrl: process.env.PUBLIC_API_URL,
  appName: process.env.PUBLIC_APP_NAME,
}))
resources/main.ts
// resources/main.ts (client)
const config = await fetch('/api/config').then((r) => r.json())
console.log(config.apiUrl, config.appName)

Vite: frontend code reads import.meta.env.VITE_API_URL. Only VITE_* vars are inlined at build time (we set envPrefix for you), the rest stay server-side.

Next.js: use Next's native NEXT_PUBLIC_* convention; Next handles the inlining.

Lifecycle Hooks

Chain onStart and onShutdown hooks. Pass a callback to start():

const { router, start, onStart, onShutdown } = await tekir({ config: { ... } })

onStart(() => console.log('Server started'))
onStart(async () => {
  // warm up cache, connect to external services, etc.
})
onShutdown(async () => {
  // flush logs, close connections, etc.
})

start(() => {
  console.log('Ready!')
})

// Graceful shutdown handled automatically in dev mode

service<T>()

service<T>(name) creates a lazy proxy. The service is resolved from the DI container on first property access, not at call time, so you can reference it before providers finish booting:

import { tekir, service } from '@tekir/core'
import { DatabaseProvider } from '@tekir/db'
import { CacheProvider } from '@tekir/cache'
import type { Database } from '@tekir/db'
import type { Cache } from '@tekir/cache'

const { router } = await tekir({
  config: { ... },
  providers: [DatabaseProvider, CacheProvider]
})

// service<T>() creates a lazy proxy: resolved on first property access
const db = service<Database>('db')
const cache = service<Cache>('cache')

// Full type safety: IDE autocomplete works
await db.exec('CREATE TABLE ...')
await db.query('SELECT * FROM users')
await cache.get('key')
await cache.set('key', 'value', 60)

Build & Deploy

Same commands for every app:

# Development (auto-detects dev mode)
tekir serve

# Build for production (runs frontend build hooks)
tekir build

# Start production server (NODE_ENV=production)
tekir serve

Single Executable

--compile bundles your app, the Bun runtime, and every embedded asset into one binary. Drop it on a server and run it, no Bun, Node, or node_modules needed. Vite frontends are supported: assets are embedded as Bun blobs and served from memory at runtime.

# Bundle app + Bun runtime + assets into a single binary.
# No Bun, Node, or node_modules needed at runtime.
tekir build --compile
./server

# Custom output name
tekir build --compile --outfile myapp

# Recommended for production: minify (default ON), sourcemap, bytecode
tekir build --compile --sourcemap --bytecode

# Skip minification (for debugging)
tekir build --compile --no-minify

# Keep build artifacts (.tekir/, dist/) for inspection.
# Without this flag they're cleaned up after the binary is written.
tekir build --compile --keep-artifacts

Apps that lean on folder-based registration (router.registerDir(...), cron.registerDir(...), emitter.registerDir(...), or loadDir(...)) need one extra dev dependency so their controllers, jobs, and listeners actually land inside the binary. See Autoload + --compile right below.

Autoload + --compile (oxc-parser)

Calls like await router.registerDir('core/controllers') and await loadDir('core/jobs')read the filesystem at runtime, which means the referenced files have to be visible to Bun's bundler too — otherwise the single executable boots with no controllers, jobs, or listeners. The compile pipeline injects a tiny AST-based plugin that finds every literal-string call, lists the directory at build time, and replaces the call with explicit static imports so the bundler picks up each file. Comments, string literals, computed-arg calls, and unrelated identifiers (registerDirectory, etc.) are left alone.

The plugin depends on oxc-parser and is enabled automatically when you install it as a dev dependency. Without it, compile prints a one-line install hint and continues, but autoload-using routes will be empty in the resulting binary:

# Install oxc-parser once as a dev dependency:
bun add -d oxc-parser

# Single-executable build (--compile auto-injects the inliner):
tekir build --compile

# Plain bundle (--outdir, no --compile): same CLI, same auto-injection.
# Forwards --target, --minify, --sourcemap, --external, --define, --plugin,
# --splitting to Bun.build. Replaces a hand-rolled `bun build` line.
tekir build --outdir ./dist --target bun --minify --sourcemap=external --external uglify-js

# Embedding directly into your own Bun.build() call (advanced):
#
#   import { createInlinerPlugin } from '@tekir/core'
#
#   await Bun.build({
#     entrypoints: ['index.ts'],
#     outdir: './dist',
#     target: 'bun',
#     plugins: [await createInlinerPlugin()],
#   })

# Without oxc-parser any of the above still build, but autoload routes
# end up empty and tekir prints:
#   [--compile] `oxc-parser` is not installed.
#   `loadDir('path')` and `*.registerDir('path')` calls will not be inlined ...

Non-literal arguments (await loadDir(dynamicVar)) are left as runtime calls, so they only work in bun run mode. Pass a literal string when you need --compile to follow the directory.

Cross-compile

Build for any target from any host. Useful in CI: produce Linux/macOS/Windows binaries from a single job. Supported targets: bun-linux-x64, bun-linux-arm64, bun-linux-x64-musl, bun-linux-arm64-musl, bun-darwin-x64, bun-darwin-arm64, bun-windows-x64, bun-windows-arm64.

# Cross-compile to other platforms from any host
tekir build --compile --target bun-linux-x64    --outfile server-linux
tekir build --compile --target bun-linux-arm64  --outfile server-linux-arm
tekir build --compile --target bun-darwin-arm64 --outfile server-mac
tekir build --compile --target bun-windows-x64  --outfile server-win.exe

# Alpine / musl-based Linux
tekir build --compile --target bun-linux-x64-musl --outfile server-alpine

Build-time constants & advanced flags

--define replaces literal references at bundle time, enabling dead-code elimination. --splitting emits lazy import() chunks next to the entry binary (use --outdir instead of --outfile). --plugin loads a Bun plugin from a TS/JS file that default-exports a BunPlugin object.

# Inject build-time constants (literals are replaced at bundle time)
tekir build --compile \
  --define BUILD_VERSION='"1.2.3"' \
  --define BUILD_TIME='"2026-04-25T10:30:00Z"'

# Embed runtime args available via process.execArgv
tekir build --compile --exec-argv "--smol --user-agent=MyBot"

# Disable hashed asset names (use original filenames)
tekir build --compile --asset-naming "[name].[ext]"

# Code splitting: lazy import() chunks emitted next to the binary
tekir build --compile --splitting --outdir ./build

# Custom Bun plugins (file default-exports a BunPlugin object)
tekir build --compile --plugin ./my-plugin.ts

# Runtime config loading inside the binary
# Bun defaults: .env + bunfig.toml ON, tsconfig + package.json OFF
tekir build --compile --autoload-tsconfig
tekir build --compile --autoload-package-json
tekir build --compile --no-autoload-dotenv
tekir build --compile --no-autoload-bunfig

Complete Example

A task manager with auth, database, cron jobs, validation, Swagger docs, and a React frontend, all in one file:

index.ts
import { tekir } from '@tekir/core'
import { cors } from '@tekir/cors'
import { bodyParser } from '@tekir/bodyparser'
import { swagger } from '@tekir/swagger'
import { limiter } from '@tekir/limiter'
import { DatabaseProvider } from '@tekir/db'
import { HashProvider } from '@tekir/hash'
import { CronProvider } from '@tekir/cron'
import type { Database } from '@tekir/db'
import type { Hash } from '@tekir/hash'
import type { Cron } from '@tekir/cron'
import { z } from 'zod'

// ── Boot ────────────────────────────────────
const { router, service, start } = await tekir({
  config: {
    app: {
      name: 'Task Manager',
      port: 3000,
      env: 'development',
      key: process.env.APP_KEY // required when using encryption, JWT, or CSRF
    },
    database: {
      default: 'sqlite',
      connections: { sqlite: { driver: 'sqlite', connection: { path: ':memory:' } } }
    },
    hash: { default: 'bcrypt', drivers: { bcrypt: { rounds: 10 } } }
  },
  providers: [DatabaseProvider, HashProvider, CronProvider],
  middleware: [cors({ origin: true })],
  routerMiddleware: [bodyParser()],
  frontend: { type: 'bun' }
})

// ── Services ────────────────────────────────
const db = service<Database>('db')
const hash = service<Hash>('hash')
const cron = service<Cron>('cron')

// ── Database ────────────────────────────────
await db.exec(`CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  email TEXT UNIQUE NOT NULL,
  password TEXT NOT NULL
)`)

await db.exec(`CREATE TABLE IF NOT EXISTS tasks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  done INTEGER DEFAULT 0,
  user_id INTEGER REFERENCES users(id)
)`)

// ── Schemas ─────────────────────────────────
const registerSchema = z.object({
  email: z.string().email(),
  password: z.string().min(6)
})

const taskSchema = z.object({
  title: z.string().min(1).max(200)
})

// ── Routes ──────────────────────────────────
router.post('/api/register', async ({ body }) => {
  const { email, password } = registerSchema.parse(body)
  const hashed = await hash.make(password)
  await db.run('INSERT INTO users (email, password) VALUES (?, ?)', [email, hashed])
  return await db.queryOne('SELECT id, email FROM users ORDER BY id DESC LIMIT 1')
})

router.get('/api/tasks', async () => await db.query('SELECT * FROM tasks'))

router.post('/api/tasks', async ({ body }) => {
  const { title } = taskSchema.parse(body)
  await db.run('INSERT INTO tasks (title) VALUES (?)', [title])
  return await db.queryOne('SELECT * FROM tasks ORDER BY id DESC LIMIT 1')
})

router.put('/api/tasks/:id/complete', async ({ params }) => {
  await db.run('UPDATE tasks SET done = 1 WHERE id = ?', [params.id])
  return await db.queryOne('SELECT * FROM tasks WHERE id = ?', [params.id])
})

router.delete('/api/tasks/:id', async ({ params }) => {
  await db.run('DELETE FROM tasks WHERE id = ?', [params.id])
  return { deleted: true }
})

// ── Cron ────────────────────────────────────
cron.add('cleanup', '0 * * * *', async () => {
  await db.run('DELETE FROM tasks WHERE done = 1')
})

// ── Swagger ─────────────────────────────────
swagger(router, { title: 'Task Manager API', version: '1.0.0', path: '/docs' })

// ── Start ───────────────────────────────────
start()

This single file gives you user registration with password hashing, task CRUD, an hourly cleanup cron, Zod validation, Swagger docs at /docs, and a React frontend with HMR. Add --compile to build a single executable.