Quick Start

Build your first route, controller, and model, then make a real HTTP request. About 10 minutes.

Before you begin: Make sure you have a tekir project set up. If not, follow the Installation guide first.

Scaffold with Templates

The fastest way to start is with the CLI. Pick one of the 5 built-in templates:

# Interactive, prompts you to choose a template
bunx create-tekir-app my-app

# Or pass --template directly
bunx create-tekir-app my-app --template=minimal    # Single-file TODO API
bunx create-tekir-app my-app --template=api         # Full API with auth, ORM, mail
bunx create-tekir-app my-app --template=fullstack   # API + React frontend
bunx create-tekir-app my-app --template=with-vite   # tekir + Vite (React/Vue/Svelte)
bunx create-tekir-app my-app --template=with-next   # tekir + Next.js SSR

Each template comes fully configured with TypeScript, hot reload, and testing. See the Installation guide for details on each template.

Minimal Single-File App

The fastest way to get started is a single-file app. tekir() is the entry point for every tekir application. It returns a TekirApp object with everything you need: app, server, router, logger, config, service, start, onStart, and onShutdown.

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

const { router, start } = await tekir()

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

start()

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

Inline API (Single File)

For real apps, tekir()accepts inline config, providers, middleware, and frontend integration in one file. Here's a complete TODO API with database, Swagger docs, and CRUD:

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

const { router, service, start } = await tekir({
  config: {
    app: { name: 'Todo API', port: 3000, env: 'development' },
    database: {
      default: 'sqlite',
      connections: { sqlite: { driver: 'sqlite', connection: { path: ':memory:' } } }
    }
  },
  providers: [DatabaseProvider]
})

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

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

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

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

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

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

swagger(router, { title: 'Todo API', version: '1.0.0', path: '/docs' })

start()

This gives you a full API server with SQLite, auto-generated Swagger docs at /docs, and typed database access, all in one file. No start/ directory, no config files needed.

Frontend Integration

tekir supports three frontend types via the frontend option. All serve on the same port: /api/* goes to tekir, everything else goes to the frontend.

Zero-config React/TS/CSS bundling with HMR. Can compile to a single executable. Put your HTML in resources/:

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

const { router, service, start } = await tekir({
  config: {
    app: { name: 'Fullstack App', port: 3000 },
    database: { default: 'sqlite', connections: { sqlite: { driver: 'sqlite', connection: { path: ':memory:' } } } }
  },
  providers: [DatabaseProvider],
  frontend: { type: 'bun' }   // Bun native, HMR, single executable
})

// API routes
const db = service<Database>('db')
router.get('/api/todos', async () => await db.query('SELECT * FROM todos'))

start()

Vite (React, Vue, Svelte, etc.)

Use Vite's plugin ecosystem. Requires @tekir/vite package. Frontend lives in resources/:

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

const { router, service, start } = await tekir({
  config: { app: { name: 'Vite App', port: 3000 } },
  providers: [DatabaseProvider],
  frontend: { type: 'vite' }   // Vite + React/Vue/Svelte
})

// API routes: /api/* goes to tekir, rest goes to Vite
router.get('/api/health', () => ({ status: 'ok' }))

start()

Next.js (SSR)

Server-side rendering with Next.js pages router. Requires @tekir/next package. Pages live in pages/:

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

const { router, service, start } = await tekir({
  config: { app: { name: 'Next App', port: 3000 } },
  providers: [DatabaseProvider],
  frontend: { type: 'next' }   // Next.js SSR (pages router)
})

// API routes: /api/* goes to tekir, rest goes to Next.js
router.get('/api/health', () => ({ status: 'ok' }))

start()

Your First Route

In a structured project, routes live in the start/ directory. Each file in start/ exports a default function that receives the TekirApp instance. The directory auto-loads all files: kernel first, boot second, then alphabetical order, and routes last.

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

const app = await tekir({
  envFile: 'env.ts',
  configDir: 'config',
  startDir: 'start',
})
app.start()

You can use the full HttpContext to access request data and build structured responses:

start/routes.ts
import type { TekirApp } from '@tekir/core'
import type { HttpContext } from '@tekir/core'

export default function({ router }: TekirApp) {
  router.get('/', ({ response }: HttpContext) => {
    return response.ok({ message: 'Hello from tekir!' })
  })

  router.get('/users/:id', ({ params, response }: HttpContext) => {
    return response.ok({ id: params.id })
  })

  router.post('/echo', ({ body, response }: HttpContext) => {
    return response.created(body)
  })
}

For anything beyond simple utility routes, controllers are the right tool.

Creating a Controller

Controllers are TypeScript classes decorated with @Controller from @tekir/http-decorators. Each method is decorated with an HTTP method decorator: @Get, @Post, @Put, @Patch, or @Delete.

Create core/controllers/post_controller.ts:

core/controllers/post_controller.ts
import { Controller, Get, Post, Put, Delete } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
import { Post } from '#models/post'

@Controller('/api/posts')
export class PostController {
  // GET /api/posts
  @Get('/')
  async index({ response }: HttpContext) {
    const posts = await Post.all()
    return response.ok(posts.map(p => p.toJSON()))
  }

  // GET /api/posts/:id
  @Get('/:id', { where: { id: { match: /^\d+$/, cast: Number } } })
  async show({ params, response }: HttpContext) {
    const post = await Post.find(Number(params.id))
    if (!post) return response.notFound({ message: 'Post not found' })
    return response.ok(post.toJSON())
  }

  // POST /api/posts
  @Post('/')
  async store({ body, response }: HttpContext) {
    const post = await Post.create(body)
    return response.created(post.toJSON())
  }

  // PUT /api/posts/:id
  @Put('/:id')
  async update({ params, body, response }: HttpContext) {
    const post = await Post.find(Number(params.id))
    if (!post) return response.notFound({ message: 'Post not found' })
    await post.merge(body).save()
    return response.ok(post.toJSON())
  }

  // DELETE /api/posts/:id
  @Delete('/:id')
  async destroy({ params, response }: HttpContext) {
    const post = await Post.find(Number(params.id))
    if (!post) return response.notFound({ message: 'Post not found' })
    await post.delete()
    return response.noContent()
  }
}

Register the controller in start/routes.ts:

start/routes.ts
import type { TekirApp } from '@tekir/core'
import { PostController } from '#controllers/post_controller'

export default function({ router }: TekirApp) {
  router.register(PostController)
}

tekir will automatically map the controller methods to the correct HTTP routes based on the decorators. The @Controller('/api/posts') decorator sets the route prefix for all methods in the class.

Creating a Model

tekir's ORM (@tekir/db) uses the ActiveRecord pattern. Models extend BaseModel and define their schema, fillable fields, and relationships declaratively.

The Model

core/models/post.ts
import { BaseModel, column, hasMany, belongsTo, type Relation } from '@tekir/db'
import { User } from './user'
import { Comment } from './comment'

export class Post extends BaseModel {
  static table = 'posts'

  static schema = {
    id: column.id(),
    title: column.string(),
    body: column.text(),
    published: column.boolean({ default: false }),
    userId: column.integer(),
    createdAt: column.dateTime({ autoCreate: true }),
    updatedAt: column.dateTime({ autoCreate: true, autoUpdate: true, nullable: true })
  }

  static fillable = ['title', 'body', 'published', 'userId']

  static relations: Record<string, Relation> = {
    author: belongsTo(() => User, { foreignKey: 'userId' }),
    comments: hasMany(() => Comment)
  }
}

// Usage
const post = await Post.create({ title: 'Hello', body: 'World', userId: 1 })
const posts = await Post.query().where('published', true).orderBy('createdAt', 'desc').limit(10)
const withAuthor = await Post.query().preload('author').first()

The Migration

Migrations define the database schema. Create one for the posts table:

database/migrations/001_create_posts_table.ts
import { BaseMigration, type Schema } from '@tekir/db'

export default class CreatePosts extends BaseMigration {
  async up(schema: Schema) {
    schema.createTable('posts', (table) => {
      table.id()
      table.string('title').notNullable()
      table.text('body').notNullable()
      table.boolean('published').defaultTo(false)
      table.integer('user_id').references('users', 'id').onDelete('CASCADE')
      table.timestamps()
    })
  }

  async down(schema: Schema) {
    schema.dropTable('posts')
  }
}

Run the migration:

tekir migrate

Adding Validation

Use @tekir/validator with @Middleware to validate request bodies. tekir integrates with Zod for schema definition:

core/controllers/post_controller.ts
import { Controller, Post, Middleware } from '@tekir/http-decorators'
import type { HttpContext } from '@tekir/core'
import { validate } from '@tekir/validator'
import { z } from 'zod'
import { Post as PostModel } from '#models/post'

const createPostSchema = z.object({
  title: z.string().min(3).max(255),
  body: z.string().min(10),
  published: z.boolean().optional().default(false)
})

@Controller('/api/posts')
export class PostController {
  @Post('/')
  @Middleware([validate({ body: createPostSchema })])
  async store({ body, response }: HttpContext) {
    // body is fully typed as z.infer<typeof createPostSchema>
    const post = await PostModel.create(body)
    return response.created(post.toJSON())
  }
}

The validate middleware validates the request body against the Zod schema and automatically returns a 422 Unprocessable Entity response with structured error messages if validation fails. If validation passes, body in the context is fully typed.

Making a Request

With the dev server running, let's test the API with curl:

Create a post

curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title":"My first post","body":"Hello from tekir!","published":true}'
{
  "id": 1,
  "title": "My first post",
  "body": "Hello from tekir!",
  "published": true,
  "userId": null,
  "createdAt": "2026-03-24T10:00:00.000Z",
  "updatedAt": null
}

List all posts

curl http://localhost:3000/api/posts

Validation errors

If you send an invalid body, tekir returns a structured error response:

curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title":"Hi"}'

Build & Deploy

Every tekir app uses the same three commands. The entry point handles everything based on your config:

# Development
tekir serve

# Build for production
tekir build

# Start production server
tekir serve

# Single executable (CLI flag)
tekir build --compile   # → ./server binary
./server                            # runs without bun installed

With frontend: { type: 'bun' } or frontend: { type: 'vite' }, running tekir build --compile produces a single executable binary that bundles your API, frontend, and all dependencies. No runtime needed. See the fluent API reference for cross-compile, --define, splitting, and the rest.

What's Next

You now have a working REST API with a controller, model, database migration, and validation. Here are some natural next steps:

  • Routing: grouped routes, named routes, route parameters with type casting, and more.
  • Middleware: global and per-route middleware, execution order, and built-in middleware.
  • Models: query scopes, hooks, computed properties, and advanced querying.
  • Authentication: JWT and session-based auth with built-in guard middleware.
  • Error Handling: custom exception handlers and structured error responses.