Skip to content
Back to skills

Stacks Api

ASecurity

Use when building, modifying, or debugging API endpoints in a Stacks application - defining routes, handling requests, API middleware, working with the API server, HTTP client (fetcher), API resources, or OpenAPI generation. Covers both @stacksjs/api utilities and the stacks-api server implementation.

  • 631 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added June 5, 2026
developmenttypescriptgobashsqlreactnodedebugginggitapidatabase

Works with

  • cli
  • api

Security analysis

A100/100

Scanned September 20, 2026

npx -y skills add stacksjs/stacks --skill stacks-api --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Stacks Api?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Stacks Api
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/stacksjs-stacks-api/badge)](https://www.skillsdirectory.com/skills/stacksjs-stacks-api)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: stacks-api
description: Use when building, modifying, or debugging API endpoints in a Stacks application - defining routes, handling requests, API middleware, working with the API server, HTTP client (fetcher), API resources, or OpenAPI generation. Covers both @stacksjs/api utilities and the stacks-api server implementation.
license: MIT
compatibility: Bun >= 1.3.0, TypeScript
allowed-tools: Read Edit Write Bash Grep Glob
---

# Stacks API

The Stacks API system comprises two packages: the `@stacksjs/api` utilities package (HTTP client, API resources, OpenAPI generation) and the `stacks-api` server (Bun HTTP server with hot-reload). Routing is delegated to `@stacksjs/router` which wraps `@stacksjs/bun-router`.

## Key Paths
- Core utilities (package): `storage/framework/core/api/src/`
- API server: `storage/framework/api/`
- Router package: `storage/framework/core/router/src/`
- Route definitions: `routes/`
- Route registry: `app/Routes.ts` (re-exports `storage/framework/defaults/app/Routes.ts`)
- Port configuration: `config/ports.ts`
- Generated OpenAPI spec: `storage/framework/api/openapi.json`
- Generated API types: `storage/framework/api/api-types.ts`
- Actions directory: `app/Actions/`
- Controllers directory: `app/Controllers/`
- Middleware directory: `app/Middleware/`
- Middleware alias map: `app/Middleware.ts`
- Package: `@stacksjs/api`

## Source Files

```
core/api/src/
├── index.ts             # Re-exports fetcher, generate-openapi, resource
├── fetcher.ts           # Fetcher HTTP client class (fluent API)
├── generate-openapi.ts  # OpenAPI 3.0 spec generator from route definitions
├── resource.ts          # JsonResource, ResourceCollection, PaginatedResourceCollection
└── types.ts             # FetcherResponse, QueryParams, BodyData, FileAttachment

framework/api/
├── dev.ts               # API server entry point (Bun --hot, file watchers)
├── build.ts             # Bun.build config for production
├── api-types.ts         # Auto-generated TypeScript types from OpenAPI spec
├── openapi.json         # Generated OpenAPI 3.0 specification
├── package.json         # Dependencies: @stacksjs/router, queue, storage, logging
└── tsconfig.json

core/router/src/
├── index.ts             # Re-exports bun-router + stacks-specific additions
├── stacks-router.ts     # Route object, serve(), url(), action/controller resolution
├── response.ts          # Response factory (json, text, html, redirect, etc.)
├── middleware.ts         # Middleware class definition
├── request-context.ts   # AsyncLocalStorage-based request context (request proxy)
├── route-loader.ts      # Loads route files from registry with auto-prefixing
├── error-handler.ts     # Ignition-style error pages, query tracking
└── action-paths.ts      # StacksActionPath type definitions

routes/
├── api.ts               # Main API routes (auto-prefixed with /api; see #1835)
├── v1.ts                # Versioned routes (loaded at /v1/*)
├── buddy.ts             # Buddy CLI routes (loaded at /buddy/*)
└── users.ts             # Example user routes (commented out)
```

## API Server (dev.ts)

The API server runs on Bun's HTTP server with hot-reload (`bun --hot run dev.ts`).

```typescript
import { serve } from '@stacksjs/router'

serve({
  port: ports.api || 3008,  // Configurable via config/ports.ts or PORT_API env
  timezone: app.timezone || 'UTC',
})
```

### Hot Reload Watchers
The dev server watches these directories for changes and auto-reloads:
- `storage/framework/core/*/src/` -- rebuilds core packages on change
- `routes/` -- detects route file changes
- `app/Actions/` -- invalidates module cache, hot-reloads actions
- `app/Controllers/` -- invalidates module cache, hot-reloads controllers
- `app/Middleware/` -- invalidates module cache, hot-reloads middleware
- `app/Models/` (user models) -- regenerates the model artifacts (there is no
  `generate:model-files` command; the dev server does this itself, and
  `buddy generate:types` is the manual equivalent)

Production build: `bun build.ts` bundles `dev.ts` to `dist/dev.js`.

## Route Definitions

### Route Registry (app/Routes.ts)

Routes are organized in `routes/*.ts` files and registered in `app/Routes.ts`:

```typescript
export interface RouteDefinition {
  path: string           // Route file path relative to routes/
  prefix?: string        // URL prefix (overrides key-based prefix)
  middleware?: string | string[]  // Middleware for all routes in file
}

export type RouteRegistry = Record<string, string | RouteDefinition>

export default {
  'api': 'api',                           // Auto-prefixed with /api (see #1835)
  'v1': { path: 'v1', prefix: 'v1' },    // routes at /v1/*
  // 'admin': { path: 'admin', middleware: ['auth'] },
} satisfies RouteRegistry
```

`'web'` is the only key that loads at root `/` with no prefix; all other keys (including `'api'`) auto-prefix with `/<key>`. The `'api'` auto-prefix lines up with the rpx proxy forward path so a user route registered as `route.get('/cart/add', ...)` in `routes/api.ts` resolves at `https://<domain>/api/cart/add` through the dev proxy.

### Route Methods

```typescript
import { route, response } from '@stacksjs/router'

// Inline handler
route.get('/hello', () => response.json({ message: 'Hello' }))
route.post('/users', () => response.created({ id: 1 }))

// String-based handler (Action)
route.get('/subscribers', 'Actions/SubscriberIndexOrmAction')
route.post('/login', 'Actions/Auth/LoginAction')

// String-based handler (Controller@method)
route.get('/coming-soon', 'Controllers/ComingSoonController@index')
route.get('/stats', 'Controllers/QueryController@getStats')

// Route with path parameters
route.get('/foo/bar/{id}', () => response.text('hello'))
route.get('/users/{id}/posts/{postId}', handler)

// Route chaining: middleware + naming
route.get('/tokens', 'Actions/Auth/ListTokensAction').middleware('auth')
route.post('/email/subscribe', 'Actions/SubscriberEmailAction').name('email.subscribe')
route.delete('/tokens/{id}', 'Actions/Auth/RevokeTokenAction').middleware('auth').name('token.revoke')

// Route groups (prefix + middleware)
route.group({ prefix: '/auth' }, () => {
  route.post('/refresh', 'Actions/Auth/RefreshTokenAction')
  route.get('/tokens', 'Actions/Auth/ListTokensAction').middleware('auth')
})

route.group({ middleware: 'auth' }, () => {
  route.get('/me', 'Actions/Auth/AuthUserAction')
  route.post('/logout', 'Actions/Auth/LogoutAction')
})

// Health check (adds GET /health)
route.health()
```

### Named Routes & URL Generation

```typescript
import { url } from '@stacksjs/router'

// Define named routes
route.get('/api/email/unsubscribe', 'Actions/UnsubscribeAction').name('email.unsubscribe')
route.get('/users/{id}/posts/{postId}', handler).name('user.post')

// Generate URLs
url('email.unsubscribe', { token: 'abc-123' })
// => https://stacksjs.com/api/email/unsubscribe?token=abc-123

url('user.post', { id: 42, postId: 7 })
// => https://stacksjs.com/users/42/posts/7
```

Path parameters (`{id}`) are substituted; remaining params become query string. Uses `APP_URL` env var as base (defaults to `https://localhost`).

## Action Resolution

String-based route handlers are resolved at runtime:

1. **Actions** (`'Actions/MyAction'`): checks `app/Actions/MyAction.ts` first, falls back to `storage/framework/defaults/app/Actions/MyAction.ts`
2. **ORM Actions** (`'Actions/UserIndexOrmAction'`): loaded from `storage/framework/actions/src/`
3. **Controllers** (`'Controllers/MyController@method'`): checks `app/Controllers/` first, falls back to defaults

Action modules must export a default object with a `handle(req: EnhancedRequest)` method:

```typescript
// app/Actions/MyAction.ts
export default {
  handle(request: EnhancedRequest): Response {
    const name = request.get('name', 'world')
    return Response.json({ message: `Hello ${name}` })
  }
}
```

Actions can also define `validations` for automatic input validation:

```typescript
export default {
  validations: {
    email: {
      rule: { validate: (value) => ({ valid: typeof value === 'string' && value.includes('@') }) },
      message: 'A valid email is required',
    },
  },
  handle(request: EnhancedRequest): Response {
    // Request is validated before reaching handle()
    return Response.json({ success: true })
  },
}
```

Validation failures return a `422` response:
```json
{ "error": "Validation failed", "errors": { "email": ["A valid email is required"] } }
```

## Response Factory

```typescript
import { response } from '@stacksjs/router'

// JSON (200)
response.json({ message: 'OK' })
response.json(data, { status: 200, headers: { 'X-Custom': 'value' }, pretty: true })

// Status shortcuts
response.created({ id: 1 })              // 201
response.noContent()                      // 204
response.badRequest({ error: 'Bad' })     // 400
response.unauthorized()                   // 401 (default: { error: 'Unauthorized' })
response.forbidden()                      // 403 (default: { error: 'Forbidden' })
response.notFound()                       // 404 (default: { error: 'Not Found' })
response.error()                          // 500 (default: { error: 'Internal Server Error' })

// Other content types
response.text('Hello', { status: 200 })
response.html('<h1>Hello</h1>')
response.redirect('/new-url', 302)        // 301 | 302 | 303 | 307 | 308
```

All methods return a standard `Response` object.

## Enhanced Request (EnhancedRequest)

Route handlers receive an `EnhancedRequest` (extends `Request`) with Laravel-style helper methods:

```typescript
// Input access (merges query params + JSON body + form body + route params)
request.get<T>(key: string, defaultValue?: T): T
request.input<T>(key: string, defaultValue?: T): T
request.all(): Record<string, unknown>
request.only<T>(keys: string[]): T
request.except<T>(keys: string[]): T

// Presence checks
request.has(key: string | string[]): boolean
request.hasAny(keys: string[]): boolean
request.filled(key: string | string[]): boolean
request.missing(key: string | string[]): boolean

// Typed getters
request.string(key: string, defaultValue?: string): string
request.integer(key: string, defaultValue?: number): number
request.float(key: string, defaultValue?: number): number
request.boolean(key: string, defaultValue?: boolean): boolean
request.array<T>(key: string): T[]

// File handling (returns UploadedFile with store/storeAs methods)
request.file(key: string): UploadedFile | null
request.getFiles(key: string): UploadedFile[]
request.hasFile(key: string): boolean
request.allFiles(): Record<string, UploadedFile | UploadedFile[]>

// Authentication (set by auth middleware)
await request.user(): Promise<any>
await request.userToken(): Promise<any>
await request.tokenCan(ability: string): Promise<boolean>
await request.tokenCant(ability: string): Promise<boolean>
request.bearerToken(): string | null
```

## Request Context (AsyncLocalStorage)

Access the current request from anywhere using the `request` proxy:

```typescript
import { request, getCurrentRequest, runWithRequest, setCurrentRequest } from '@stacksjs/router'

// The `request` export is a Proxy that reads from AsyncLocalStorage
const token = request.bearerToken()
const user = await request.user()

// Manually run code in a request context
runWithRequest(enhancedReq, () => {
  // request proxy works here
})
```

## Middleware

### Defining Middleware

```typescript
// app/Middleware/Auth.ts
import { Middleware } from '@stacksjs/router'

export default new Middleware({
  name: 'Auth',
  priority: 1,        // Lower numbers run first (default: 10)
  async handle(request) {
    const token = request.bearerToken()
    if (!token) {
      const error = new Error('Unauthorized') as Error & { statusCode: number }
      error.statusCode = 401
      throw error
    }
    // Set authenticated user on request
    ;(request as any)._authenticatedUser = user
  },
})
```

### Middleware Alias Map

Define shorthand names in `app/Middleware.ts`:

```typescript
export default {
  auth: 'Auth',                     // maps to app/Middleware/Auth.ts
  verified: 'EnsureEmailIsVerified',
  abilities: 'CheckAbilities',      // supports params: 'abilities:read,write'
}
```

### Middleware Resolution Order
1. Parse alias via `app/Middleware.ts` (e.g., `'auth'` -> `'Auth'`)
2. Load from `app/Middleware/Auth.ts` (user override)
3. Fall back to `storage/framework/defaults/app/Middleware/Auth.ts`

### Middleware with Parameters

```typescript
route.get('/admin', handler).middleware('abilities:admin,write')
// Params accessible in middleware via: (request as any)._middlewareParams.abilities === 'admin,write'
```

## Which client to reach for

Stacks has three, and they answer different questions. Pick by who is calling.

| Caller | Client | Why |
|---|---|---|
| TypeScript in this repo or a workspace package | **Typed client** (below) | Full input/output inference with **no generation step**. Change a route, the call site stops compiling. |
| Anything that is not TypeScript here - native iOS/Android via the Craft bridge, third-party integrators, Swagger UI | **Generated REST client** (`buddy generate:openapi`) | Needs a real spec. That pipeline is unchanged and permanent. |
| One-off calls to any URL | **`Fetcher`** (below) | No route awareness, and none intended. |

The first two are both permanent. This is not a migration off REST.

## Typed client (zero generation)

Register routes with `createTypedRouter()` and the compiler can see both ends —
no `buddy generate:openapi`, no committed file to go stale.

```typescript
// routes/api.ts
import IndexAction from '../app/Actions/Project/IndexAction'
import StoreAction from '../app/Actions/Project/StoreAction'
import { createTypedRouter } from '@stacksjs/router'

export const api = createTypedRouter()
  .get('/v1/projects', IndexAction)
  .get('/v1/projects/{id}', ShowAction)
  .post('/v1/projects', StoreAction, { middleware: 'auth' })

export type AppRoutes = typeof api
```

```typescript
// any TypeScript consumer
import type { AppRoutes } from '../routes/api'
import { createTypedClient } from '@stacksjs/router'

const client = createTypedClient<AppRoutes>({ baseUrl: 'https://api.example.com' })

const projects = await client.get('/v1/projects')
const one = await client.get('/v1/projects/{id}', { params: { id: '42' } })
const created = await client.post('/v1/projects', { name: 'apollo', budget: 1200 })
```

An unknown path is a compile error. A body that does not match the action's
`validations` is a compile error. The result is the action's own return type.

**How it infers.** Input comes from the action's `validations` — the same object
the validator runs, so the two cannot drift. Output comes from `handle`'s return
type. An action returning a `Response` or a stream is typed `unknown`, honestly:
it took over the wire format.

**Per-route settings are an argument**, not a chained call — chaining
`.middleware()` would return the route and lose the accumulated type:

```typescript
.post('/v1/projects', StoreAction, {
  middleware: ['auth', 'can:create,project'],
  name: 'projects.store',
  skipCsrf: true,
  rateLimit: { max: 10, window: 'minute' },
})
```

**No `.group()`.** A prefix applied only at runtime makes every accumulated path
type wrong; one applied only in the type is a second place for the URL to live.
Write the full path.

**The string form is untouched.** `route.get('/x', 'Actions/Foo')` stays exactly
as it is — lazy import, hot-reload friendly — and simply produces no inferred
types. A route wanting inference opts in by using the builder instead. Routes
registered either way still land in the generated OpenAPI document.

**Generated CRUD.** A `useApi` model's endpoints are describable too, without
touching them:

```typescript
import type { ApiRoutesFor } from '@stacksjs/orm'
import type Product from '../app/Models/Product'

type AppRoutes = RoutesOf<typeof api> & ApiRoutesFor<typeof Product>
await client.get('/api/products')  // typed listing envelope
```

The builder, the client and the route-map contract all live in
`@stacksjs/bun-router` and are re-exported by `@stacksjs/router`. Import
`createTypedClient` from `@stacksjs/bun-router` directly in a browser bundle.

## Fetcher (HTTP Client)

A fluent HTTP client for ad hoc requests, with no route awareness:

```typescript
import { fetcher } from '@stacksjs/api'

// GET request
const res = await fetcher.get<UserResponse>('/api/users')
res.data       // UserResponse
res.status     // 200
res.isOk       // true
res.ok()       // true (status === 200)
res.notFound() // false

// POST with JSON body
const res = await fetcher
  .withBody({ name: 'John', email: 'john@example.com' })
  .post<User>('/api/users')

// PUT/PATCH/DELETE
await fetcher.withBody(data).put<User>('/api/users/1')
await fetcher.withBody(data).patch<User>('/api/users/1')
await fetcher.delete('/api/users/1')

// Query parameters
await fetcher.withQueryParams({ page: 1, limit: 25 }).get('/api/users')

// Custom headers
await fetcher.withHeaders({ 'X-Custom': 'value' }).get('/api/users')

// Authentication
await fetcher.withToken('bearer-token-here').get('/api/me')
await fetcher.withBasicAuth('user', 'pass').get('/api/protected')
await fetcher.withDigestAuth('user', 'pass').get('/api/digest-protected')

// Form data
await fetcher.asForm().withBody({ email: 'test@test.com' }).post('/subscribe')

// File uploads (multipart/form-data)
await fetcher
  .attach('avatar', file, 'photo.jpg')
  .withBody({ name: 'John' })
  .post('/api/upload')

// Accept header
await fetcher.acceptJson().get('/api/data')
await fetcher.accept('text/xml').get('/api/feed')
```

### FetcherResponse<T>

```typescript
interface FetcherResponse<T = any> {
  data: T
  status: number
  headers: Headers
  isOk: boolean

  // Status checkers
  ok(): boolean             // 200
  created(): boolean        // 201
  accepted(): boolean       // 202
  noContent(): boolean      // 204
  movedPermanently(): boolean  // 301
  found(): boolean          // 302
  badRequest(): boolean     // 400
  unauthorized(): boolean   // 401
  paymentRequired(): boolean   // 402
  forbidden(): boolean      // 403
  notFound(): boolean       // 404
  requestTimeout(): boolean    // 408
  conflict(): boolean       // 409
  unprocessableEntity(): boolean // 422
  tooManyRequests(): boolean   // 429
  serverError(): boolean    // 500
}
```

### Fetcher Types

```typescript
type QueryParams = Record<string, string | number | boolean | null | undefined>
type BodyData = Record<string, any>

interface FileAttachment {
  name: string
  content: Blob
  filename?: string
  headers?: Record<string, string>
}
```

## API Resources (Laravel-style)

### JsonResource

Transform models into JSON responses with conditional field inclusion:

```typescript
import { JsonResource } from '@stacksjs/api'

class UserResource extends JsonResource<User> {
  toArray() {
    return {
      id: this.resource.id,
      name: this.resource.name,
      email: this.resource.email,
      // Conditional inclusion
      secret: this.when(isAdmin, this.resource.secret),
      avatar: this.whenNotNull(this.resource.avatar),
      // Relationship loading
      posts: this.whenLoaded('posts', () => PostResource.collection(this.resource.posts)),
      posts_count: this.whenCounted('posts'),
      // Conditional merge
      ...this.mergeWhen(isAdmin, { admin_notes: this.resource.adminNotes }),
    }
  }
}

// Single resource
const response = new UserResource(user).toResponse()
// => { data: { id: 1, name: 'John', email: 'john@...' } }

// With additional data
new UserResource(user).withAdditional({ meta: { version: 1 } }).toResponse()

// JSON string
new UserResource(user).toJson()
```

### JsonResource Methods

```typescript
abstract class JsonResource<T> {
  resource: T
  additional: Record<string, any>
  static wrap: string | null = 'data'  // Wrap key, null to disable

  abstract toArray(request?: Request): Record<string, any>

  // Instance methods
  withRequest(request: Request): this
  withAdditional(data: Record<string, any>): this
  resolve(request?: Request): Record<string, any>
  toResponse(request?: Request): Record<string, any>
  toJson(request?: Request): string

  // Static methods
  static collection<T, R>(resources: T[]): ResourceCollection<T, R>
  static withoutWrapping(): void     // Set wrap = null for this class
  static wrapWith(key: string): void // Set custom wrap key

  // Conditional helpers (protected, use inside toArray)
  protected when<V>(condition: boolean | (() => boolean), value: V | (() => V), defaultValue?: any): V | MissingValue
  protected whenNotNull<V>(value: V | null | undefined, transform?: (v: V) => any): any | MissingValue
  protected whenLoaded<V>(relationship: string, value?: V | (() => V), defaultValue?: any): V | any[] | MissingValue
  protected whenCounted(relationship: string, defaultValue?: number): number | MissingValue
  protected merge(data: Record<string, any>): MergeValue
  protected mergeWhen(condition: boolean | (() => boolean), data: Record<string, any> | (() => Record<string, any>)): MergeValue | MissingValue
}
```

### ResourceCollection

```typescript
class ResourceCollection<T, R extends JsonResource<T>> {
  resources: T[]
  static wrap: string | null = 'data'

  withRequest(request: Request): this
  withAdditional(data: Record<string, any>): this
  resolve(request?: Request): Record<string, any>[]
  toResponse(request?: Request): Record<string, any>
  toJson(request?: Request): string
  count(): number
  isEmpty(): boolean
  isNotEmpty(): boolean
}

// Usage
const collection = UserResource.collection(users)
collection.toResponse()
// => { data: [{ id: 1, ... }, { id: 2, ... }] }
```

### PaginatedResourceCollection

```typescript
class PaginatedResourceCollection<T, R extends JsonResource<T>> extends ResourceCollection<T, R> {
  meta: PaginationMeta
  links: PaginationLinks

  // Create from raw pagination data
  static fromPagination<T, R>(
    data: T[],
    ResourceClass: new (resource: T) => R,
    options: { currentPage: number, perPage: number, total: number, baseUrl?: string },
  ): PaginatedResourceCollection<T, R>

  toResponse(): Record<string, any>
  // => { data: [...], meta: { current_page, from, last_page, per_page, to, total }, links: { first, last, prev, next } }
}

interface PaginationMeta {
  current_page: number
  from: number | null
  last_page: number
  per_page: number
  to: number | null
  total: number
}

interface PaginationLinks {
  first: string | null
  last: string | null
  prev: string | null
  next: string | null
}
```

### Anonymous Resources

Quick inline transformations without defining a class:

```typescript
import { resource, collection } from '@stacksjs/api'

// Single resource
resource(user, (u) => ({ id: u.id, name: u.name })).toResponse()
// => { data: { id: 1, name: 'John' } }

// Collection
collection(users, (u) => ({ id: u.id, name: u.name })).toResponse()
// => { data: [{ id: 1, name: 'John' }, ...] }
```

### Conditional Value Types

```typescript
class MissingValue {
  static instance: MissingValue
  isMissing(): boolean
}

class MergeValue {
  constructor(data: Record<string, any>)
}

class ConditionalValue {
  constructor(condition: boolean | (() => boolean), value: any, defaultValue?: any)
  resolve(): any
}
```

`MissingValue` entries are stripped from the final output by `filterAndResolve()` (max depth: 20).

## OpenAPI Generation

```typescript
import { generateOpenApi } from '@stacksjs/api'

await generateOpenApi()
// Reads all registered routes via `route.routes()`
// Writes OpenAPI 3.0 spec to storage/framework/api/openapi.json
```

The generated spec includes:
- Path definitions from route URLs
- HTTP methods from route registrations
- Path parameters extracted from `{param}` placeholders
- Response/request schemas if defined on routes (`route.responseSchema`, `route.requestSchema`)
- Operation IDs from route callbacks (action paths)

TypeScript types can be generated from the spec:
```bash
# Defined in package.json scripts
bun run generate-types
# Runs: open-api ./../../api/openapi.json --output ./../../api/api-types.ts
```

## Error Handling

### Error Response Structure

```typescript
interface ErrorResponseBody {
  error: string
  message: string
  status: number
  timestamp: string
  details?: Record<string, unknown>
}
```

### Development vs Production
- **Development**: Ignition-style HTML error pages with full stack traces, database queries, and request context
- **Production**: Simple JSON or HTML error responses (no stack traces)
- Detection: `APP_ENV !== 'production' && NODE_ENV !== 'production'`
- API requests (Accept: `application/json`): always get JSON error responses

### Error Functions

```typescript
import {
  createErrorResponse,
  createMiddlewareErrorResponse,
  createValidationErrorResponse,
  createNotFoundResponse,
  trackQuery,
  clearTrackedQueries,
} from '@stacksjs/router'

// Track queries for error context (circular buffer, max 50)
trackQuery('SELECT * FROM users', 12.5, 'sqlite')

// Create error responses
await createErrorResponse(error, request, { status: 500, handlerPath: 'Actions/MyAction' })
await createMiddlewareErrorResponse(error, request)  // Uses error.statusCode
createValidationErrorResponse({ email: ['Required'] }, request)  // 422
await createNotFoundResponse('/missing-path', request)  // 404
```

### Sensitive Data Sanitization
Error pages automatically redact fields matching these patterns: `password`, `secret`, `token`, `api_key`, `authorization`, `credential`, `cookie`, `session_id`, and others.

## Port Configuration (config/ports.ts)

```typescript
{
  frontend: env.PORT ?? 3000,
  backend: env.PORT_BACKEND ?? 3001,
  admin: env.PORT_ADMIN ?? 3002,
  api: env.PORT_API ?? 3008,
  // ... other services
}
```

## Route Groups in routes/api.ts

The main `routes/api.ts` file defines route groups for major feature areas:

| Prefix | Feature | Actions Prefix |
|--------|---------|---------------|
| `/auth` | Authentication & tokens | `Actions/Auth/` |
| `/password` | Password reset flow | `Actions/Password/` |
| `/payments` | Stripe payment integration | `Actions/Payment/` |
| `/commerce` | E-commerce (products, orders, customers, coupons, gift cards, tax rates, reviews, receipts, POS, waitlists) | `Actions/Commerce/` |
| `/shipping` | Shipping methods, rates, zones, delivery routes, drivers, digital delivery, license keys | `Actions/Commerce/Shipping/` |
| `/cms` | Blog posts, authors, categories, tags, comments, pages | `Actions/Cms/` |
| `/blog` | Public blog endpoints (read-only) | `Actions/Cms/` |
| `/monitoring` | Error tracking & resolution | `Actions/Monitoring/` |
| `/dashboard` | Dashboard stats & health | `Actions/Dashboard/` |
| `/queries` | Query log dashboard | `Controllers/QueryController@` |
| `/queues` | Queue management | `Actions/Queue/` |
| `/realtime` | WebSocket management | `Actions/Realtime/` |
| `/voide` | Voice AI code assistant | `Actions/Buddy/` |

## ORM-Generated CRUD Routes

Models with `traits.useApi` automatically get CRUD routes via ORM actions:

| Method | Path | Handler | Status |
|--------|------|---------|--------|
| GET | `/api/{model}` | Generated index | 200 |
| POST | `/api/{model}` | Generated store | 201 |
| GET | `/api/{model}/{id}` | Generated show | 200 |
| PUT/PATCH | `/api/{model}/{id}` | Generated update | 200 |
| DELETE | `/api/{model}/{id}` | Generated destroy | 204 |

Generated store and update handlers accept declared `fillable` attributes and
the foreign-key attributes derived from `belongsTo`. For example,
`belongsTo: ['Product', 'Customer']` accepts `productId` / `product_id` and
`customerId` / `customer_id`. Other undeclared fields remain guarded.

## CLI Commands

- `buddy dev` -- starts the API server with hot-reload
- `buddy generate:types` -- regenerates the API TypeScript types and server
  declarations (there is no `generate:api-types`)
- `buddy generate:openapi-spec` -- regenerates the OpenAPI spec those types come from

## Gotchas

- The `fetcher` is a **singleton instance** -- state (headers, body, query params) is reset after each request via `resetState()`, but calling chain methods without executing a request will accumulate state
- `fetcher.withDigestAuth()` uses SHA-256 internally despite the method name `generateMD5` -- this is a misnomer in the source code
- `fetcher` always parses responses as JSON (`response.json()`) -- it will throw if the response is not valid JSON
- Only the `'web'` route file key loads at root `/` with no prefix; all other keys (including `'api'`) auto-prefix with `/<key>`. The `'api'` auto-prefix was added in stacksjs/stacks#1835 so user routes line up with the rpx proxy forward path
- The `route` object is a global singleton from `stacks-router.ts` -- all route definitions across files share the same router instance
- String-based handlers (`'Actions/MyAction'`) are resolved lazily at request time, not at registration time -- import errors surface only when the route is hit
- Actions must export `default` with a `handle()` method; Controllers must export `default` as a class with the specified method (defaults to `index`)
- The `request` proxy from `@stacksjs/router` returns safe defaults (null, undefined, empty) when accessed outside of a request context -- it warns in non-production
- `JsonResource.filterAndResolve()` has a max recursion depth of 20 to prevent stack overflow on circular references
- `ResourceCollection.toResponse()` and `JsonResource.toResponse()` both wrap data in `{ data: ... }` by default -- call `MyResource.withoutWrapping()` to disable
- The error handler tracks up to 50 recent queries in a circular buffer -- these are cleared after each successful response
- Middleware runs in registration order (not by priority) within a single route -- the `priority` field on `Middleware` is for documentation/ordering within files
- Middleware parameters (e.g., `'abilities:read,write'`) are stored on `request._middlewareParams` -- middleware must parse the param string itself
- The dev server's file watchers use Node's `fs.watch` (not `chokidar`) -- on macOS this uses FSEvents, on Linux it uses inotify
- `openapi.json` starts empty (`{}`) and is only populated when `generateOpenApi()` is explicitly called
- The `api-types.ts` file uses operations keyed by action path (e.g., `'Actions/UserIndexOrmAction'`), not by route name
- CORS headers (`Access-Control-Allow-Origin: http://localhost:5173`) are only added in development mode for error responses
- Body parsing happens once per request (tracked via `_bodyParsed` flag) -- JSON, URL-encoded, and multipart/form-data are all supported
- `response.json()` catches circular reference errors and returns `{ error: 'Response data could not be serialized' }` instead of throwing

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.