Actions
Master queries and mutations in Igniter.js to create type-safe API endpoints with automatic validation, full IntelliSense, and end-to-end type inference.
Overview
Actions are the core building blocks of your Igniter.js API. They define individual endpoints and come in two types:
- Query - For reading data (GET requests)
- Mutation - For modifying data (POST, PUT, DELETE, PATCH requests)
import { igniter } from '@/igniter';
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
// Query action
list: igniter.query({
name: 'List Users',
description: 'Retrieve a list of all users',
path: '/',
handler: async ({ context, response }) => {
const users = await context.db.users.findMany();
return response.success({ users });
}
}),
// Mutation action
create: igniter.mutation({
name: 'Create User',
description: 'Create a new user account',
path: '/',
method: 'POST',
body: CreateUserSchema,
handler: async ({ request, response }) => {
const user = await context.db.users.create(request.body);
return response.created({ user });
}
})
}
});Query Actions
Query actions are for reading data. They use the GET HTTP method and should be idempotent (multiple calls produce the same result).
Basic Query
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
list: igniter.query({
name: 'List Users',
description: 'Retrieve a list of all users',
path: '/',
handler: async ({ context, response }) => {
const users = await context.db.users.findMany();
return response.success({ users });
}
})
}
});
// Creates endpoint: GET /users/Query with Path Parameters
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
getById: igniter.query({
name: 'Get User by ID',
description: 'Retrieve a specific user by their ID',
path: '/:id' as const, // add 'as const' to ensure type inference
handler: async ({ request, context, response }) => {
// ✅ request.params.id is automatically typed as string
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 });
}
})
}
});
// Creates endpoint: GET /users/:idAutomatic Type Inference
Path parameters are automatically extracted and typed from the path string! /:id → params.id: string
Query with Query Parameters
Use Zod schemas to validate and type query parameters:
import { z } from 'zod';
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
search: igniter.query({
name: 'Search Users',
description: 'Search users by name with pagination',
path: '/search',
query: z.object({
q: z.string().min(1),
limit: z.coerce.number().optional().default(10),
offset: z.coerce.number().optional().default(0)
}),
handler: async ({ request, context, response }) => {
// ✅ request.query is fully typed and validated
const { q, limit, offset } = request.query;
const users = await context.db.users.findMany({
where: {
name: { contains: q, mode: 'insensitive' }
},
take: limit,
skip: offset
});
return response.success({ users, query: q, limit, offset });
}
})
}
});
// Creates endpoint: GET /users/search?q=john&limit=20&offset=0z.coerce for Query Params
Query parameters come as strings from URLs. Use z.coerce.number() to automatically convert them to numbers!
Query Options
Prop
Type
OpenAPI Documentation
The name and description properties are crucial for generating comprehensive OpenAPI specifications for your API. 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.
Mutation Actions
Mutation actions are for modifying data. They support POST, PUT, PATCH, and DELETE methods.
POST Mutation (Create)
import { z } from 'zod';
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
create: igniter.mutation({
name: 'Create User',
description: 'Create a new user account',
path: '/',
method: 'POST',
body: z.object({
name: z.string().min(2),
email: z.string().email(),
age: z.number().int().min(18).optional()
}),
handler: async ({ request, context, response }) => {
// ✅ request.body is fully typed from CreateUserSchema
const user = await context.db.users.create({
data: request.body
});
return response.created({ user });
}
})
}
});
// Creates endpoint: POST /users/PUT Mutation (Update)
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
update: igniter.mutation({
name: 'Update User',
description: 'Update an existing user by ID',
path: '/:id' as const,
method: 'PUT',
body: z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
age: z.number().int().min(18).optional()
}),
handler: async ({ request, context, response }) => {
// ✅ request.params.id is typed
// ✅ request.body is typed from UpdateUserSchema
const user = await context.db.users.update({
where: { id: request.params.id },
data: request.body
});
return response.success({ user });
}
})
}
});
// Creates endpoint: PUT /users/:idDELETE Mutation
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
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 successfully' });
}
})
}
});
// Creates endpoint: DELETE /users/:idPATCH Mutation (Partial Update)
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
patch: igniter.mutation({
name: 'Patch User',
description: 'Partially update a user by ID',
path: '/:id' as const,
method: 'PATCH',
body: z.object({
name: z.string().min(2),
email: z.string().email()
}).partial(),
handler: async ({ request, context, response }) => {
const user = await context.db.users.update({
where: { id: request.params.id },
data: request.body
});
return response.success({ user });
}
})
}
});
// Creates endpoint: PATCH /users/:idMutation Options
Prop
Type
Action Handler
Every action has a handler function that processes the request and returns a response.
Handler Context
The handler receives a context object with:
handler: async ({ request, context, response, plugins }) => {
// ...
}Prop
Type
Request Object
handler: async ({ request }) => {
// HTTP method
console.log(request.method); // "GET" | "POST" | "PUT" | etc.
// URL path
console.log(request.path); // "/users/123"
// Path parameters (from /:param)
console.log(request.params.id); // "123" (typed!)
// Query parameters (if schema defined)
console.log(request.query.q); // "search" (typed!)
// Request body (if schema defined)
console.log(request.body.name); // "John" (typed!)
// Headers
const auth = request.headers.get('authorization');
// Cookies
const session = request.cookies.get('sessionId');
// Raw Web Request object
const rawRequest = request.raw;
}Context Object
handler: async ({ context }) => {
// Access app context (defined in Igniter.context())
const users = await context.db.users.findMany();
// Access user (if using dynamic context)
if (context.user) {
console.log('Authenticated:', context.user.email);
}
// Access services
await context.email.send({ to: 'user@example.com' });
}Response Object
handler: async ({ response }) => {
// 200 OK with data
return response.success({ users: [] });
// 201 Created
return response.created({ user: newUser });
// 404 Not Found
return response.notFound({ message: 'User not found' });
// 400 Bad Request
return response.badRequest({ message: 'Invalid input' });
// 401 Unauthorized
return response.unauthorized({ message: 'Authentication required' });
// 500 Internal Server Error
return response.error({ message: 'Something went wrong' });
// Custom response
return response.json({ custom: 'data' }, { status: 418 });
}Plugins Object
handler: async ({ plugins, response }) => {
// Call plugin actions (type-safe!)
await plugins.audit.actions.create({
action: 'user:created',
userId: '123'
});
await plugins.email.actions.sendWelcome({
to: 'user@example.com',
name: 'John'
});
return response.success({ message: 'Done' });
}Validation
Body Validation
const postController = igniter.controller({
name: 'Posts',
description: 'Manage blog posts and articles',
path: '/posts',
actions: {
create: igniter.mutation({
name: 'Create Post',
description: 'Create a new blog post',
path: '/',
method: 'POST',
body: z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
published: z.boolean().default(false),
tags: z.array(z.string()).max(5).optional()
}),
handler: async ({ request, response }) => {
// ✅ request.body is fully typed and validated
const post = await db.posts.create({
data: request.body
});
return response.created({ post });
}
})
}
});If validation fails, Igniter.js automatically returns a 400 Bad Request with error details:
{
"error": "Validation failed",
"issues": [
{
"path": ["title"],
"message": "String must contain at least 3 character(s)"
}
]
}Query Validation
const productController = igniter.controller({
path: '/products',
actions: {
search: igniter.query({
path: '/search',
query: z.object({
q: z.string().min(1),
category: z.enum(['tech', 'design', 'business']).optional(),
minPrice: z.coerce.number().min(0).optional(),
maxPrice: z.coerce.number().min(0).optional(),
sortBy: z.enum(['price', 'date', 'popularity']).default('date')
}),
handler: async ({ request, response }) => {
const { q, category, minPrice, maxPrice, sortBy } = request.query;
const products = await db.products.findMany({
where: {
name: { contains: q },
category: category,
price: {
gte: minPrice,
lte: maxPrice
}
},
orderBy: { [sortBy]: 'desc' }
});
return response.success({ products });
}
})
}
});Combined Validation
You can validate both body and query parameters:
const userController = igniter.controller({
name: 'Users',
description: 'Manage user accounts and profiles',
path: '/users',
actions: {
createWithInvite: igniter.mutation({
name: 'Create User with Invite',
description: 'Create a new user account using an invite code',
path: '/',
method: 'POST',
body: z.object({
name: z.string(),
email: z.string().email()
}),
query: z.object({
inviteCode: z.string().length(8)
}),
handler: async ({ request, response }) => {
// ✅ Both request.body and request.query are typed
const { name, email } = request.body;
const { inviteCode } = request.query;
// Verify invite
const invite = await db.invites.findUnique({
where: { code: inviteCode }
});
if (!invite) {
return response.badRequest({ message: 'Invalid invite code' });
}
// Create user
const user = await db.users.create({ data: { name, email } });
return response.created({ user });
}
})
}
});
// Usage: POST /users?inviteCode=ABC12345
// Body: { "name": "John", "email": "john@example.com" }Procedures (Middleware)
Add reusable logic before your handler using procedures:
const authProcedure = igniter.procedure({
handler: async ({ context, request }) => {
const token = request.headers.get('authorization');
if (!token) {
throw new Error('No authorization token');
}
const user = await verifyToken(token);
// ✅ Extend context with currentUser
return {
currentUser: user
};
}
});
const protectedController = igniter.controller({
path: '/protected',
actions: {
getData: igniter.query({
path: '/',
use: [authProcedure], // ← Apply middleware
handler: async ({ context, response }) => {
// ✅ context.currentUser is available!
return response.success({
message: `Hello, ${context.currentUser.name}`
});
}
})
}
});Error Handling
Throwing Errors
const userController = igniter.controller({
path: '/users',
actions: {
getById: igniter.query({
path: '/:id' as const,
handler: async ({ request, context, response }) => {
const user = await context.db.users.findUnique({
where: { id: request.params.id }
});
if (!user) {
// ✅ Option 1: Return error response
return response.notFound({ message: 'User not found' });
}
// ✅ Option 2: Throw error (caught automatically)
if (!user.isActive) {
throw new Error('User account is deactivated');
}
return response.success({ user });
}
})
}
});Custom Error Responses
class UserNotFoundError extends Error {
constructor(userId: string) {
super(`User ${userId} not found`);
this.name = 'UserNotFoundError';
}
}
const handler = async ({ request, response }) => {
try {
const user = await findUser(request.params.id);
return response.success({ user });
} catch (error) {
if (error instanceof UserNotFoundError) {
return response.notFound({ message: error.message });
}
return response.error({ message: 'Unexpected error' });
}
};Advanced Patterns
Conditional Responses
const userController = igniter.controller({
path: '/users',
actions: {
list: igniter.query({
path: '/',
query: z.object({
format: z.enum(['json', 'csv']).default('json')
}),
handler: async ({ request, context, response }) => {
const users = await context.db.users.findMany();
if (request.query.format === 'csv') {
const csv = convertToCSV(users);
return new Response(csv, {
headers: { 'Content-Type': 'text/csv' }
});
}
return response.success({ users });
}
})
}
});Streaming Responses
const fileController = igniter.controller({
path: '/files',
actions: {
download: igniter.query({
path: '/:id/download' as const,
handler: async ({ request, context }) => {
const file = await context.db.files.findUnique({
where: { id: request.params.id }
});
const stream = await context.storage.getStream(file.path);
return new Response(stream, {
headers: {
'Content-Type': file.mimeType,
'Content-Disposition': `attachment; filename="${file.name}"`
}
});
}
})
}
});Pagination
const postController = igniter.controller({
path: '/posts',
actions: {
list: igniter.query({
path: '/',
query: z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20)
}),
handler: async ({ request, context, response }) => {
const { page, limit } = request.query;
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
context.db.posts.findMany({ skip, take: limit }),
context.db.posts.count()
]);
return response.success({
posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
}
});
}
})
}
});Maintaining Actions
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 actions. This is required before using the client in your frontend.
When you add a new action 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
Next Steps
Controllers
Learn how to organize actions into logical controllers with shared paths, group related endpoints, and build modular APIs with Igniter.js.
Procedures
Create reusable middleware with Igniter.js procedures to handle authentication, validation, logging, and cross-cutting concerns with full type safety.