Skip to content

Controllers ​

Controllers are the presentation layer in KickJS. They handle HTTP requests, delegate to use cases or services, and send responses. A controller is a class decorated with @Controller() that defines route handlers using method decorators.

Scaffold one

kick g controller <name> writes a controller with typed Ctx<KickRoutes...> handlers already wired, and reruns kick typegen so the types exist before you open the file.

pnpm exec kick g controller users

-m, --module <module> places it inside a module folder. kick g module writes one for you as part of the module. Full flag list: Generators.

Defining a Controller ​

ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Patch,
  Autowired,
  reply,
  type Ctx,
} from '@forinda/kickjs'

@Controller()
export class TodoController {
  @Autowired() private readonly createTodoUseCase!: CreateTodoUseCase

  @Post('/', { body: createTodoSchema })
  async create(ctx: Ctx<KickRoutes.TodoController['create']>) {
    return reply.created(await this.createTodoUseCase.execute(ctx.body))
  }

  @Get('/')
  async list(ctx: Ctx<KickRoutes.TodoController['list']>) {
    return this.listTodosUseCase.execute()
  }

  @Get('/:id')
  async getById(ctx: Ctx<KickRoutes.TodoController['getById']>) {
    const result = await this.getTodoUseCase.execute(ctx.params.id)
    if (!result) {
      ctx.problem.notFound({ detail: `Todo ${ctx.params.id} not found` })
      return
    }
    return result
  }

  @Delete('/:id')
  async remove(ctx: Ctx<KickRoutes.TodoController['remove']>) {
    await this.deleteTodoUseCase.execute(ctx.params.id)
    return reply.noContent()
  }
}

Handlers return their payload — the runtime sends it, and kick typegen reads the return type to fill in KickRoutes[...].response. That inferred type is what a typed client consumes, so the response style is not just cosmetic: a handler that calls ctx.json() and returns nothing infers as unknown. See Return-Value Handlers for the full rules, and keep error branches on ctx.problem.* — those send immediately and return nothing, so a 404 never widens the success type.

Ctx<KickRoutes.X['method']> is the type-safe handler signature. The KickRoutes global namespace is generated by kick typegen (auto-run on kick dev and after kick g module/kick g controller/kick g scaffold) and gives you fully-typed ctx.params, ctx.body, and ctx.query from the URL pattern, the Zod schema in the route decorator, and @ApiQueryParams respectively. See Type Generation for the full picture.

The loose RequestContext type still works for backward compatibility — Ctx<> is opt-in per handler.

@Controller Decorator ​

@Controller() registers the class in the DI container as a singleton and marks it as a controller. It takes no arguments:

ts
@Controller()
export class AdminController { ... }

@Controller('/path') was removed in v4

The signature is Controller(): ClassDecorator — passing a path is a TypeScript error, not an ignored argument. In v3 the path was accepted as Swagger-only metadata, which made it look like it set the route prefix when it never did. See Migration v3 → v4.

The route prefix comes from the module ​

The mount prefix is the module's routes().path — the single source of truth for where routes live:

ts
export const AdminModule = defineModule({
  name: 'AdminModule',
  build: () => ({
    routes() {
      return { path: '/admin', controller: AdminController }
    },
  }),
})

@Controller()
export class AdminController {
  @Get('/stats') // resolves to /api/v1/admin/stats
  async stats(ctx: RequestContext) { ... }
}

/api is apiPrefix and /v1 is defaultVersion; a module can drop either with version: false or prefix: false on its ModuleRoutes. See Modules.

Route Decorators ​

Five HTTP method decorators are available, each accepting an optional path and an optional validation schema:

ts
@Get(path?, validation?)
@Post(path?, validation?)
@Put(path?, validation?)
@Delete(path?, validation?)
@Patch(path?, validation?)

The validation argument accepts Zod schemas for body, query, and params:

ts
@Post('/', { body: createTodoSchema })
@Put('/:id', { body: updateTodoSchema })
@Get('/search', { query: searchQuerySchema })

When validation is provided, the framework runs the validate() middleware before the handler. See the Validation page for details.

RequestContext ​

Every handler receives a RequestContext instance that wraps the raw Express request and response. It is generic over body, params, and query types:

ts
class RequestContext<TBody = any, TParams = any, TQuery = any>

Request data ​

PropertyTypeDescription
bodyTBodyParsed request body
paramsTParamsRoute parameters (e.g. /:id)
queryTQueryQuery string parameters
headersIncomingHttpHeadersRequest headers
requestIdstring | undefinedValue of x-request-id header
fileanySingle uploaded file (with @FileUpload)
filesany[] | undefinedArray of uploaded files

Query string parsing ​

The qs() method parses structured query parameters (filters, sort, pagination):

ts
@Get('/')
async list(ctx: RequestContext) {
  const parsed = ctx.qs({
    filterable: ['status', 'priority'],
    sortable: ['createdAt', 'title'],
  })
  // parsed.filters, parsed.sort, parsed.pagination, parsed.search
}

Metadata store ​

ctx.set(key, value) and ctx.get<T>(key) provide a per-request key-value store. Middleware can attach data (e.g. authenticated user) for handlers to read.

Response helpers ​

Prefer returning the payload

Returning the object is the recommended way to write a handler, and what the CLI scaffolds. Reach for these ctx.* helpers when you need imperative control — streaming, custom headers, or a branch that writes and exits early.

These helpers terminate the response: they write immediately, which is why a ctx.* call always wins over a return value. They remain fully supported.

What they cost you is the response type. A handler that ends return ctx.json(user) — or uses a helper and returns nothing — infers as response: unknown, because the helper hands back the engine's response object rather than the body, so the typed client has no payload to offer.

MethodStatusDescription
ctx.json(data, status?)200JSON response
ctx.created(data)201Created resource
ctx.noContent()204No body
ctx.problem.notFound(input?)404Not found, as RFC 9457 problem+json
ctx.problem.badRequest(input?)400Bad request, as RFC 9457 problem+json
ctx.notFound(message?)404Deprecated — use ctx.problem.notFound()
ctx.badRequest(message)400Deprecated — use ctx.problem.badRequest()
ctx.html(content, status?)200HTML response
ctx.redirect(url, status?)302Redirect (works on every runtime)
ctx.download(buffer, filename, type?)--File download
ctx.render(template, data?)200Render a template (requires ViewAdapter)
await ctx.sendResponse(response)--Send a web Response, body streamed

Redirect destinations

ctx.redirect(url) writes url into the Location header unchanged. A destination taken from the request (?next=…, a form field) must be allow-listed or limited to a same-site path — one leading /, not // — or the route becomes an open redirect.

Returning a generated file ​

ctx.download() sets Content-Disposition and Content-Type and sends the buffer. It goes through the same runtime driver as every other helper here, so it works unchanged on Express, Fastify and h3:

ts
@Get('/students.xlsx')
async export(ctx: RequestContext) {
  const file = await this.reports.buildStudentsWorkbook()
  return ctx.download(
    file,
    'students.xlsx',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  )
}

Reaching for ctx.res.setHeader() + ctx.res.end() instead is the common way to end up runtime-locked: FastifyReply has no setHeader, and h3's event has no end. See HTTP runtimes.

Pagination ​

ctx.paginate() parses query params, calls your fetcher, and returns a standardized paginated response. It both sends the response and returns the payload, so return ctx.paginate(...) carries PaginatedResponse<T> through to KickRoutes and the typed client:

ts
@Get('/')
@ApiQueryParams({ filterable: ['status'], sortable: ['createdAt'] })
async list(ctx: RequestContext) {
  return ctx.paginate(
    async (parsed) => {
      const data = await this.repo.findPaginated(parsed)
      return data // { data: T[], total: number }
    },
    { filterable: ['status'], sortable: ['createdAt'] },
  )
}

Response shape:

json
{
  "data": [...],
  "meta": {
    "page": 1,
    "limit": 10,
    "total": 42,
    "totalPages": 5,
    "hasNext": true,
    "hasPrev": false
  }
}

Template Rendering ​

Render server-side templates using the configured view engine (requires ViewAdapter):

ts
@Get('/dashboard')
async dashboard(ctx: RequestContext) {
  ctx.render('dashboard', { user: ctx.req.user, title: 'Dashboard' })
}

Server-Sent Events ​

ctx.sse() starts an SSE stream for real-time updates:

ts
@Get('/events')
async stream(ctx: RequestContext) {
  const sse = ctx.sse()

  const interval = setInterval(() => {
    sse.send({ time: new Date().toISOString() }, 'tick')
  }, 1000)

  sse.onClose(() => clearInterval(interval))
}

SSE helpers:

MethodDescription
sse.send(data, event?, id?)Send an event to the client
sse.comment(text)Send a keep-alive comment
sse.onClose(fn)Register disconnect callback
sse.close()End the stream

Return-Value Handlers ​

The default way to write a handler. Return the response payload and the runtime auto-sends it as 200 application/json when the handler wrote nothing. This is what the CLI scaffolds (kick g module, kick g controller, kick g scaffold), what keeps KickRoutes and the typed client exact, and it works on every runtime (Express, Fastify, h3, h3-web, and the edge fetch entry):

ts
@Get('/:id')
async get(ctx: RequestContext) {
  return this.users.find(ctx.params.id) // → 200 json
}

For a non-200 status, wrap with reply() — the wrapper carries the status in its type, so response inference stays exact:

ts
import { reply } from '@forinda/kickjs'

@Post('/')
async create(ctx: RequestContext) {
  return reply(201, await this.users.create(ctx.body)) // → 201 json
}

@Delete('/:id')
async remove(ctx: RequestContext) {
  await this.users.remove(ctx.params.id)
  return reply.noContent() // → empty 204
}

Rules of precedence:

  • A ctx.* response (e.g. ctx.json) always wins. It terminates the response — the bytes are already on the wire — so the runtimes only auto-send a returned value when nothing was written (if (!res.headersSent)). This is the original, Express-shaped path and it stays authoritative; return values are additive on top of it, never a replacement.
  • return ctx.json(user) types the response as unknown. The helper hands back the engine's response object, which says nothing about the body — so the typed client gets no payload type. Return the value (return user) or wrap it (return reply(201, user)) to keep inference exact.
  • Returning undefined/void changes nothing — pure imperative handlers behave exactly as before.
  • Sugars: reply.ok(body) (200), reply.created(body) (201), reply.accepted(body) (202), reply.noContent() (204). reply.ok(body) is the explicit form of a bare return body for any defined body — useful when a handler mixes statuses and you want every branch to read alike. The two differ for undefined: a bare return undefined sends nothing (the imperative path stays in charge), while reply.ok(undefined) is an explicit 200 with an empty json body.

Returning values is what makes the handler's response type statically inferable — the foundation for typed-client generation. kick typegen fills KickRoutes[...].response with InferHandlerResponse<Controller['method']>, which reads the method's return type and nothing else:

Handler styleInferred response
return payloadthe payload's type
return reply.created(payload)the payload's type (Reply<S, T> → T)
ctx.json(payload) then no returnunknown
return ctx.json(payload)unknown — the helper reports no payload type
return ctx.paginate(fetcher)PaginatedResponse<T> — sends AND returns its payload

The two ctx.json rows are the same case: ctx.json() returns the runtime's response driver for fluent chaining, and a driver says nothing about the body — so inference has no payload to report either way. Return x directly to type the route.

(That row used to read RuntimeResponse, and it was accurate: the driver object itself leaked into KickRoutes and the typed client, offering .status() / .setHeader() where a payload belonged. It degrades to unknown now — no type rather than a confidently wrong one.)

Error branches ​

Send errors through ctx.problem.* (RFC 9457 problem+json) and return nothing. Because the branch contributes undefined, which inference drops, the route's success type stays clean:

ts
@Get('/:id')
async get(ctx: Ctx<KickRoutes.UserController['get']>) {
  const user = await this.users.find(ctx.params.id)
  if (!user) {
    ctx.problem.notFound({ detail: `User ${ctx.params.id} not found` })
    return // response stays `User` — the `undefined` branch is dropped
  }
  return user
}

Non-2xx responses reach the typed client as a KickClientError carrying the problem body, so they belong on the error channel rather than in the success type.

Middleware on Controllers ​

Use @Middleware() at the class or method level. See Middleware for the full guide.

ts
import { Controller, Get, Middleware } from '@forinda/kickjs'

@Controller()
@Middleware(authMiddleware) // runs on all routes in this controller
export class SecureController {
  @Get('/public')
  @Middleware(rateLimitMiddleware) // runs only on this route
  async publicEndpoint(ctx: RequestContext) {
    return { ok: true }
  }
}

Dependency Injection ​

Use @Autowired() for property injection. Dependencies are resolved lazily from the DI container:

ts
@Controller()
export class TodoController {
  @Autowired() private todoService!: TodoService
  @Autowired() private logger!: AppLogger
}

For constructor injection with interface tokens, use @Inject():

ts
constructor(
  @Inject(TODO_REPOSITORY) private readonly repo: ITodoRepository,
) {}

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.