03: Your First Feature
Generate and understand your first Igniter.js feature
Now that we have our database ready, let's create our first feature using the powerful Igniter.js CLI generator.
Generating the Feature
The Igniter.js CLI has a powerful command that generates a complete feature structure based on your Prisma schema. This saves you from writing boilerplate code and ensures consistency across your application.
Run this command:
igniter generate feature link --schema prisma:LinkThis single command does a lot of heavy lifting:
- Reads the
Linkmodel from yourschema.prisma - Creates the entire folder structure
- Generates controllers with CRUD endpoints
- Generates services for database operations
- Configures end-to-end TypeScript types
What is a Feature?
In Igniter.js, a "feature" is a self-contained module that groups all logic related to a specific entity or business domain. It includes controllers (HTTP endpoints), services (business logic and database access), procedures (middleware), and interfaces (types, schemas, and constants).
Understanding the Generated Structure
After running the command, you'll see this structure in src/features/link/:
src/features/link/
├── controllers/
│ └── link.controller.ts # HTTP endpoints (GET, POST, PUT, DELETE)
├── services/
│ └── link.service.ts # Business logic and database operations
├── procedures/
│ └── link.procedure.ts # Service injection into context
└── link.interfaces.ts # Types, Zod schemas, and constantsEach file has a specific responsibility. Let's explore them one by one.
The Controller: Handling HTTP Requests
Controllers are the entry point for HTTP requests. They handle request validation, call business logic, and format responses. Open src/features/link/controllers/link.controller.ts:
import { igniter } from "@/igniter";
import { z } from "zod";
import { linkProcedure } from "../procedures/link.procedure";
import { CreateLinkBodySchema, UpdateLinkBodySchema } from "../link.interfaces";
export const linkController = igniter.controller({
name: "link",
path: "/link",
actions: {
// GET /link - List all links
list: igniter.action({
method: "GET",
path: "/",
use: [linkProcedure],
handler: async ({ context, response }) => {
const links = await context.link.service.link.findMany();
return response.success(links);
},
}),
// GET /link/:id - Get a specific link
get: igniter.action({
method: "GET",
path: "/:id" as const,
use: [linkProcedure],
handler: async ({ context, response, request }) => {
const link = await context.link.service.link.findUnique(
request.params.id
);
if (!link) {
return response.notFound("Link not found");
}
return response.success(link);
},
}),
// POST /link - Create a new link
create: igniter.action({
method: "POST",
path: "/",
body: CreateLinkBodySchema,
use: [linkProcedure],
handler: async ({ context, response, request }) => {
const link = await context.link.service.link.create(request.body);
return response.created(link);
},
}),
// PUT /link/:id - Update a link
update: igniter.action({
method: "PUT",
path: "/:id" as const,
body: UpdateLinkBodySchema,
use: [linkProcedure],
handler: async ({ context, response, request }) => {
const link = await context.link.service.link.update(
request.params.id,
request.body
);
if (!link) {
return response.notFound("Link not found");
}
return response.success(link);
},
}),
// DELETE /link/:id - Delete a link
delete: igniter.action({
method: "DELETE",
path: "/:id" as const,
use: [linkProcedure],
handler: async ({ context, response, request }) => {
await context.link.service.link.delete(request.params.id);
return response.noContent();
},
}),
},
});Key Concepts in Controllers
1. Controller Configuration: The controller is configured with a name (for documentation) and a path (the base URL path for all actions).
2. Actions: Each action is an HTTP endpoint. The method defines the HTTP verb (GET, POST, etc.), the path is the URL pattern, and use specifies the procedures (middleware) to run before the handler. Notice the as const on paths with parameters—this enables TypeScript to infer the correct types for request.params.
3. Request Validation: The body property on create and update actions automatically validates incoming data using Zod schemas. If validation fails, the request is rejected with a clear error message.
4. Context Usage: The handler accesses the service through context.link.service.link. This dependency was injected by the linkProcedure.
5. Response Methods: Igniter.js provides semantic response methods like response.success(), response.created(), response.notFound(), and response.noContent(). These ensure correct HTTP status codes and consistent response formats.
The Service: Encapsulating Business Logic
Services encapsulate all database operations and business logic for a feature. This keeps controllers thin and focused, while making the business logic reusable and testable.
Open src/features/link/services/link.service.ts:
import { PrismaClient, Link } from "@prisma/client";
import { CreateLinkBody, UpdateLinkBody } from "../link.interfaces";
/**
* Service responsible for Link entity business logic and data access
*/
export class LinkService {
constructor(private prisma: PrismaClient) {}
/**
* Find all links with related user data
*/
async findMany() {
return this.prisma.link.findMany({
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
/**
* Find a single link by ID with full relations
*/
async findUnique(id: string) {
return this.prisma.link.findUnique({
where: { id },
include: {
user: true,
clicks: true,
},
});
}
/**
* Create a new link
*/
async create(data: CreateLinkBody) {
return this.prisma.link.create({
data,
});
}
/**
* Update an existing link
*/
async update(id: string, data: UpdateLinkBody) {
return this.prisma.link.update({
where: { id },
data,
});
}
/**
* Delete a link
*/
async delete(id: string) {
return this.prisma.link.delete({
where: { id },
});
}
}Why Services Matter
Separation of Concerns: Controllers handle HTTP. Services handle business logic. This separation makes code easier to test and maintain.
Reusability: Service methods can be called from multiple controllers, background jobs, or even other services.
Business Logic: Services can include validation rules, calculations, and complex queries beyond simple CRUD operations.
Type Safety: The service class is fully typed, providing autocomplete and compile-time error checking.
The Procedure: Injecting Dependencies
Procedures in Igniter.js are middleware that extend the request context. They're perfect for injecting services, validating authentication, or adding any shared functionality.
Open src/features/link/procedures/link.procedure.ts:
import { igniter } from "@/igniter";
import { LinkService } from "../services/link.service";
/**
* Procedure that injects LinkService into the request context
*/
export const linkProcedure = igniter.procedure({
name: "linkProcedure",
handler: async ({ context }) => {
return {
link: {
service: {
link: new LinkService(context.database),
},
},
};
},
});Understanding Procedures
Context Extension: The procedure returns an object that gets merged into the request context. This makes the service available to all downstream handlers.
Type Safety: TypeScript automatically infers what's available in context.link.service.link based on the returned object.
Dependency Injection: By instantiating the service here, we keep controllers clean and make it easy to swap implementations for testing.
Composition: Multiple procedures can be chained in the use array at the action level. Each procedure runs in order, extending the context with its returned values. This builds up the context step by step before the handler executes.
The Interfaces: Centralizing Types
All types, Zod schemas, and constants for a feature live in one place. Open src/features/link/link.interfaces.ts:
import { z } from "zod";
/**
* Schema for creating a new link
*/
export const CreateLinkBodySchema = z.object({
shortCode: z.string().min(3, "Short code must be at least 3 characters"),
url: z.string().url("Must be a valid URL"),
userId: z.string(),
});
/**
* Schema for updating a link
*/
export const UpdateLinkBodySchema = z.object({
shortCode: z.string().min(3, "Short code must be at least 3 characters").optional(),
url: z.string().url("Must be a valid URL").optional(),
});
/**
* Type for creating a new link
*/
export type CreateLinkBody = z.infer<typeof CreateLinkBodySchema>;
/**
* Type for updating a link
*/
export type UpdateLinkBody = z.infer<typeof UpdateLinkBodySchema>;Having all schemas in one file makes them easy to find, update, and reuse.
Testing with Igniter Studio
Now for the magic. Instead of using npm run dev, use the Igniter.js CLI:
igniter devThis command is more than just a dev server. It:
- Starts your Next.js application
- Watches for file changes
- Auto-generates the TypeScript client when controllers change
- Auto-generates API documentation
- Provides hot module reload
Why use igniter dev?
The igniter dev command monitors your controllers and automatically regenerates the TypeScript client and documentation whenever you make changes. This keeps your frontend and backend perfectly synchronized with zero manual work!
Once running, navigate to http://localhost:3000/api/igniter/studio. You'll see:
- All your endpoints listed and organized
- Interactive forms to test each endpoint
- Real-time Zod validation as you type
- Formatted responses with syntax highlighting
- Automatic TypeScript types generated from your schemas
Try creating a link:
- Click on
POST /linkin the studio - Fill in the test form:
{ "shortCode": "test", "url": "https://example.com", "userId": "<id-from-seed-data>" } - Click "Send"
You should see a 201 Created response with your new link!
Making API Requests
You can also test with curl or any HTTP client:
curl -X POST http://localhost:3000/api/link \
-H "Content-Type: application/json" \
-d '{
"shortCode": "docs",
"url": "https://igniterjs.com/docs",
"userId": "your-user-id-here"
}'