Controllers
Learn how to organize actions into logical controllers with shared paths, group related endpoints, and build modular APIs with Igniter.js.
Overview
Controllers group related actions together under a common path prefix. They help you organize your API into logical modules, making it easier to maintain and understand.
import { igniter } from '@/igniter';
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
list: igniter.query({ name: 'List Users', path: '/', handler: ... }),
getById: igniter.query({ name: 'Get User', path: '/:id' as const, handler: ... }),
create: igniter.mutation({ name: 'Create User', path: '/', method: 'POST', handler: ... }),
update: igniter.mutation({ name: 'Update User', path: '/:id' as const, method: 'PUT', handler: ... }),
delete: igniter.mutation({ name: 'Delete User', path: '/:id' as const, method: 'DELETE', handler: ... })
}
});What is a Controller?
A controller is a collection of related actions that share a common path prefix. All actions inside a controller automatically inherit the controller's path.
Creating a Controller
Basic Controller
import { igniter } from '@/igniter';
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
list: igniter.query({
name: 'List Users',
description: 'Retrieve all users',
path: '/',
handler: async ({ context, response }) => {
const users = await context.db.users.findMany();
return response.success({ users });
}
})
}
});
// Creates endpoint: GET /users/Controller with Multiple Actions
import { igniter } from '@/igniter';
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email()
});
const UpdateUserSchema = CreateUserSchema.partial();
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
// GET /users/
list: igniter.query({
name: 'List Users',
description: 'Retrieve all users',
path: '/',
handler: async ({ context, response }) => {
const users = await context.db.users.findMany();
return response.success({ users });
}
}),
// GET /users/:id
getById: igniter.query({
name: 'Get User by ID',
description: 'Retrieve a specific user by their ID',
path: '/:id' as const,
handler: async ({ request, context, response }) => {
const user = await context.db.users.findUnique({
where: { id: request.params.id }
});
if (!user) {
return response.notFound({ message: 'User not found' });
}
return response.success({ user });
}
}),
// POST /users/
create: igniter.mutation({
name: 'Create User',
description: 'Create a new user account',
path: '/',
method: 'POST',
body: CreateUserSchema,
handler: async ({ request, context, response }) => {
const user = await context.db.users.create({
data: request.body
});
return response.created({ user });
}
}),
// PUT /users/:id
update: igniter.mutation({
name: 'Update User',
description: 'Update an existing user by ID',
path: '/:id' as const,
method: 'PUT',
body: UpdateUserSchema,
handler: async ({ request, context, response }) => {
const user = await context.db.users.update({
where: { id: request.params.id },
data: request.body
});
return response.success({ user });
}
}),
// DELETE /users/:id
delete: igniter.mutation({
name: 'Delete User',
description: 'Delete a user by ID',
path: '/:id' as const,
method: 'DELETE',
handler: async ({ request, context, response }) => {
await context.db.users.delete({
where: { id: request.params.id }
});
return response.success({ message: 'User deleted' });
}
})
}
});Controller Configuration
Path Prefix
The path property sets the base path for all actions in the controller:
const postController = igniter.controller({
name: 'Posts',
description: 'Manage blog posts and articles',
path: '/posts', // ← Base path
actions: {
list: igniter.query({ name: 'List Posts', path: '/', ... }), // → GET /posts/
getById: igniter.query({ name: 'Get Post', path: '/:id' as const, ... }), // → GET /posts/:id
create: igniter.mutation({ name: 'Create Post', path: '/', ... }) // → POST /posts/
}
});Automatic Path Combination
Controller path (/posts) + Action path (/:id) = Full route (/posts/:id)
Name and Description
Add metadata for documentation and OpenAPI generation:
const userController = igniter.controller({
name: 'Users',
path: '/users',
description: 'User management endpoints for authentication and profile data',
actions: {
// ...
}
});OpenAPI Documentation
The name and description properties are crucial for generating comprehensive OpenAPI specifications. They enable the CLI to create detailed documentation that powers Igniter Studio (our interactive API playground), making your API much more discoverable and user-friendly.
Prop
Type
Organizing Controllers
Feature-Based Organization
Group controllers by feature or domain:
// features/users/user.controller.ts
export const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: { /* ... */ }
});
// features/posts/post.controller.ts
export const postController = igniter.controller({
name: 'Posts',
description: 'Manage blog posts and articles',
path: '/posts',
actions: { /* ... */ }
});
// igniter.router.ts
import { userController } from '@/features/users/user.controller';
import { postController } from '@/features/posts/post.controller';
export const AppRouter = igniter.router({
controllers: {
users: userController,
posts: postController
}
});Nested Paths
Create hierarchical APIs with nested paths:
const postCommentController = igniter.controller({
name: 'Post Comments',
description: 'Manage comments on blog posts',
path: '/posts/:postId/comments' as const,
actions: {
// GET /posts/:postId/comments/
list: igniter.query({
name: 'List Post Comments',
description: 'Get all comments for a specific post',
path: '/',
handler: async ({ request, context, response }) => {
const comments = await context.db.comments.findMany({
where: { postId: request.params.postId }
});
return response.success({ comments });
}
}),
// POST /posts/:postId/comments/
create: igniter.mutation({
name: 'Create Comment',
description: 'Add a new comment to a post',
path: '/',
method: 'POST',
body: CreateCommentSchema,
handler: async ({ request, context, response }) => {
const comment = await context.db.comments.create({
data: {
...request.body,
postId: request.params.postId
}
});
return response.created({ comment });
}
}),
// GET /posts/:postId/comments/:id
getById: igniter.query({
name: 'Get Comment',
description: 'Retrieve a specific comment by ID',
path: '/:id' as const,
handler: async ({ request, context, response }) => {
const comment = await context.db.comments.findUnique({
where: {
id: request.params.id,
postId: request.params.postId
}
});
if (!comment) {
return response.notFound({ message: 'Comment not found' });
}
return response.success({ comment });
}
})
}
});Common Patterns
CRUD Controller
A complete CRUD (Create, Read, Update, Delete) controller:
import { igniter } from '@/igniter';
import { z } from 'zod';
const CreateSchema = z.object({
name: z.string(),
description: z.string()
});
const UpdateSchema = CreateSchema.partial();
function createCrudController<T extends string>(resource: T) {
const capitalizedResource = resource.charAt(0).toUpperCase() + resource.slice(1);
return igniter.controller({
name: `${capitalizedResource}`,
description: `Manage ${resource} resources`,
path: `/${resource}`,
actions: {
// List all
list: igniter.query({
name: `List ${capitalizedResource}`,
description: `Retrieve all ${resource}`,
path: '/',
handler: async ({ context, response }) => {
const items = await context.db[resource].findMany();
return response.success({ [resource]: items });
}
}),
// Get one by ID
getById: igniter.query({
name: `Get ${capitalizedResource}`,
description: `Retrieve a specific ${resource} by ID`,
path: '/:id' as const,
handler: async ({ request, context, response }) => {
const item = await context.db[resource].findUnique({
where: { id: request.params.id }
});
if (!item) {
return response.notFound({ message: `${resource} not found` });
}
return response.success({ [resource]: item });
}
}),
// Create new
create: igniter.mutation({
name: `Create ${capitalizedResource}`,
description: `Create a new ${resource}`,
path: '/',
method: 'POST',
body: CreateSchema,
handler: async ({ request, context, response }) => {
const item = await context.db[resource].create({
data: request.body
});
return response.created({ [resource]: item });
}
}),
// Update existing
update: igniter.mutation({
name: `Update ${capitalizedResource}`,
description: `Update an existing ${resource} by ID`,
path: '/:id' as const,
method: 'PUT',
body: UpdateSchema,
handler: async ({ request, context, response }) => {
const item = await context.db[resource].update({
where: { id: request.params.id },
data: request.body
});
return response.success({ [resource]: item });
}
}),
// Delete
delete: igniter.mutation({
name: `Delete ${capitalizedResource}`,
description: `Delete a ${resource} by ID`,
path: '/:id' as const,
method: 'DELETE',
handler: async ({ request, context, response }) => {
await context.db[resource].delete({
where: { id: request.params.id }
});
return response.success({ message: `${resource} deleted` });
}
})
}
});
}
// Usage
const productController = createCrudController('products');
const categoryController = createCrudController('categories');Search & Filter Controller
const SearchSchema = z.object({
q: z.string().min(1),
category: z.string().optional(),
minPrice: z.coerce.number().optional(),
maxPrice: z.coerce.number().optional(),
sortBy: z.enum(['price', 'date', 'name']).default('date'),
order: z.enum(['asc', 'desc']).default('desc'),
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20)
});
const productController = igniter.controller({
name: 'Products',
description: 'Manage product catalog with search and filtering',
path: '/products',
actions: {
search: igniter.query({
name: 'Search Products',
description: 'Search and filter products with pagination',
path: '/search',
query: SearchSchema,
handler: async ({ request, context, response }) => {
const { q, category, minPrice, maxPrice, sortBy, order, page, limit } = request.query;
const skip = (page - 1) * limit;
const [products, total] = await Promise.all([
context.db.products.findMany({
where: {
name: { contains: q, mode: 'insensitive' },
category: category,
price: {
gte: minPrice,
lte: maxPrice
}
},
orderBy: { [sortBy]: order },
skip,
take: limit
}),
context.db.products.count({
where: {
name: { contains: q, mode: 'insensitive' },
category: category
}
})
]);
return response.success({
products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
});
}
})
}
});Protected Controller
Apply authentication to all actions in a controller:
const authProcedure = igniter.procedure({
handler: async ({ context, request }) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
throw new Error('Authentication required');
}
const user = await verifyToken(token);
return {
currentUser: user
};
}
});
const protectedController = igniter.controller({
name: 'Protected',
description: 'Protected endpoints requiring authentication',
path: '/protected',
actions: {
getData: igniter.query({
name: 'Get Protected Data',
description: 'Retrieve user-specific protected data',
path: '/data',
use: [authProcedure],
handler: async ({ context, response }) => {
return response.success({
message: `Hello, ${context.currentUser.name}`
});
}
}),
getSettings: igniter.query({
name: 'Get User Settings',
description: 'Retrieve user settings and preferences',
path: '/settings',
use: [authProcedure],
handler: async ({ context, response }) => {
const settings = await context.db.settings.findUnique({
where: { userId: context.currentUser.id }
});
return response.success({ settings });
}
})
}
});Versioned Controllers
Create multiple versions of the same controller:
// v1/user.controller.ts
export const userControllerV1 = igniter.controller({
name: 'Users API v1',
description: 'Legacy user management API',
path: '/v1/users',
actions: {
list: igniter.query({
name: 'List Users v1',
description: 'Retrieve users (legacy format)',
path: '/',
handler: async ({ context, response }) => {
// Old implementation
const users = await context.db.users.findMany({
select: { id: true, name: true, email: true }
});
return response.success({ users });
}
})
}
});
// v2/user.controller.ts
export const userControllerV2 = igniter.controller({
name: 'Users API v2',
description: 'Enhanced user management API',
path: '/v2/users',
actions: {
list: igniter.query({
name: 'List Users v2',
description: 'Retrieve users with extended information',
path: '/',
handler: async ({ context, response }) => {
// New implementation with additional fields
const users = await context.db.users.findMany({
select: {
id: true,
name: true,
email: true,
avatar: true, // ← New field
createdAt: true, // ← New field
role: true // ← New field
}
});
return response.success({ users });
}
})
}
});
// Router
export const AppRouter = igniter.router({
controllers: {
usersV1: userControllerV1, // /v1/users
usersV2: userControllerV2 // /v2/users
}
});Best Practices
1. Group by Domain
Organize controllers by business domain, not technical layers:
// ✅ Good - Domain-based
const userController = igniter.controller({ path: '/users', ... });
const postController = igniter.controller({ path: '/posts', ... });
const commentController = igniter.controller({ path: '/comments', ... });
// ❌ Bad - Layer-based
const readController = igniter.controller({ path: '/read', ... });
const writeController = igniter.controller({ path: '/write', ... });2. Keep Controllers Focused
Each controller should handle one resource or feature:
// ✅ Good - Single responsibility
const userController = igniter.controller({
path: '/users',
actions: {
list: ...,
getById: ...,
create: ...,
update: ...,
delete: ...
}
});
// ❌ Bad - Mixed responsibilities
const apiController = igniter.controller({
path: '/api',
actions: {
getUsers: ...,
getPosts: ...,
getComments: ...,
createUser: ...,
createPost: ...
}
});3. Use Consistent Naming
Follow RESTful conventions for action names:
const resourceController = igniter.controller({
name: 'Resources',
description: 'Generic resource management',
path: '/resources',
actions: {
list: igniter.query({
name: 'List Resources',
description: 'Retrieve all resources',
path: '/', ...
}), // GET /resources
getById: igniter.query({
name: 'Get Resource',
description: 'Retrieve a specific resource by ID',
path: '/:id' as const, ...
}), // GET /resources/:id
create: igniter.mutation({
name: 'Create Resource',
description: 'Create a new resource',
method: 'POST', ...
}), // POST /resources
update: igniter.mutation({
name: 'Update Resource',
description: 'Update an existing resource',
method: 'PUT', ...
}), // PUT /resources/:id
delete: igniter.mutation({
name: 'Delete Resource',
description: 'Delete a resource by ID',
method: 'DELETE', ...
}) // DELETE /resources/:id
}
});4. Extract Shared Logic
Use procedures for shared validation or authorization:
const adminOnlyProcedure = igniter.procedure({
handler: async ({ context }) => {
if (!context.user?.isAdmin) {
throw new Error('Admin access required');
}
return {};
}
});
const adminController = igniter.controller({
path: '/admin',
actions: {
getUsers: igniter.query({
use: [adminOnlyProcedure],
handler: ...
}),
deleteUser: igniter.mutation({
use: [adminOnlyProcedure],
handler: ...
})
}
});Testing Controllers
Unit Tests
Test individual actions:
import { describe, it, expect } from 'vitest';
import { AppRouter } from '@/igniter.router';
describe('User Controller', () => {
it('should list all users', async () => {
const result = await AppRouter.caller.users.list.query();
expect(result.data.users).toBeInstanceOf(Array);
});
it('should get user by ID', async () => {
const result = await AppRouter.caller.users.getById.query({ id: '1' });
expect(result.data.user).toBeDefined();
expect(result.data.user.id).toBe('1');
});
it('should create a user', async () => {
const result = await AppRouter.caller.users.create.mutate({
body: {
name: 'Test User',
email: 'test@example.com'
}
});
expect(result.data.user.name).toBe('Test User');
});
});Integration Tests
Test full request flow:
import { describe, it, expect } from 'vitest';
describe('User API Integration', () => {
it('should handle user lifecycle', async () => {
// Create
const created = await AppRouter.caller.users.create.mutate({
body: { name: 'John', email: 'john@example.com' }
});
const userId = created.data.user.id;
// Read
const read = await AppRouter.caller.users.getById.query({ id: userId });
expect(read.data.user.name).toBe('John');
// Update
const updated = await AppRouter.caller.users.update.mutate({
params: { id: userId },
body: { name: 'Jane' }
});
expect(updated.data.user.name).toBe('Jane');
// Delete
await AppRouter.caller.users.delete.mutate({ params: { id: userId } });
// Verify deletion
await expect(
AppRouter.caller.users.getById.query({ id: userId })
).rejects.toThrow();
});
});Maintaining Controllers
Schema Generation After Changes
Critical: You MUST run schema generation BEFORE using the client in your frontend. The client depends on the generated schema to function correctly.
Run this command whenever you add, modify, or remove controllers. This is required before using the client in your frontend.
When you add a new controller or modify existing ones, regenerate the client schema:
npx @igniter-js/cli@latest generate schemapnpm dlx @igniter-js/cli@latest generate schemayarn dlx @igniter-js/cli@latest generate schemabunx @igniter-js/cli@latest generate schemaThis updates:
src/igniter.schema.ts- Type definitions for your clientsrc/docs/openapi.json- OpenAPI specification