next-api-controllers
v1.1.0
Published
ASP.NET-style controllers and decorators for Next.js App Router
Maintainers
Readme
next-controllers
ASP.NET-style controllers and decorators for Next.js App Router. Build type-safe, organized APIs with a familiar decorator-based approach instead of deeply nested file-based routing.
Motivation
Next.js App Router uses file-based routing, which can become cumbersome for complex APIs:
app/api/users/route.ts
app/api/users/[id]/route.ts
app/api/users/[id]/posts/route.ts
app/api/users/[id]/posts/[postId]/route.tsWith next-controllers, organize your API using controllers and decorators:
@Controller('/users')
export class UserController {
@Get('/')
getUsers() { /* ... */ }
@Get('/:id')
getUser(@Route('id') id: string) { /* ... */ }
@Get('/:id/posts')
getUserPosts(@Route('id') id: string) { /* ... */ }
@Get('/:id/posts/:postId')
getPost(@Route('id') id: string, @Route('postId') postId: string) { /* ... */ }
}Features
- 🎯 ASP.NET-style decorators - Familiar API design patterns
- 🔐 Built-in authentication - JWT and session-based auth support
- 🛡️ Role-based authorization -
@Authorize()decorator with role checks - ✅ Zod validation - Automatic request body validation
- 🔒 Custom guards - Flexible authorization logic
- 🎭 Middleware support - Route-level and controller-level middleware
- 💉 Dependency injection - Lightweight DI container
- 📦 Type-safe - Full TypeScript support
- ⚡ Performance - Routes compiled once at startup
- 🪶 Lightweight - Minimal dependencies
Installation
npm install next-controllerspnpm add next-controllersyarn add next-controllersQuick Start
1. Enable TypeScript Decorators
Update your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true
}
}2. Create a Controller
// app/controllers/user.controller.ts
import { Controller, Get, Post, Body, Route } from 'next-controllers'
@Controller('/users')
export class UserController {
@Get('/')
getUsers() {
return Response.json([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' }
])
}
@Get('/:id')
getUser(@Route('id') id: string) {
return Response.json({ id, name: 'John' })
}
@Post('/')
createUser(@Body() body: any) {
return Response.json({ id: 3, ...body }, { status: 201 })
}
}3. Register Controllers with Next.js
Create a catch-all route handler:
// app/api/[...all]/route.ts
import { createNextHandler } from 'next-controllers'
import { UserController } from '@/controllers/user.controller'
export const { GET, POST, PUT, DELETE, PATCH } = createNextHandler({
controllers: [UserController]
})4. Start Your App
npm run devYour API is now available:
GET /api/users- Get all usersGET /api/users/123- Get user by IDPOST /api/users- Create user
Core Concepts
Controllers
Controllers group related routes together:
@Controller('/products')
export class ProductController {
@Get('/')
getAllProducts() { }
@Get('/:id')
getProduct(@Route('id') id: string) { }
@Post('/')
createProduct(@Body() body: CreateProductDto) { }
}HTTP Method Decorators
Available decorators:
@Get(path)- Handle GET requests@Post(path)- Handle POST requests@Put(path)- Handle PUT requests@Delete(path)- Handle DELETE requests@Patch(path)- Handle PATCH requests
@Controller('/posts')
export class PostController {
@Get('/')
getPosts() { }
@Post('/')
createPost() { }
@Put('/:id')
updatePost() { }
@Delete('/:id')
deletePost() { }
@Patch('/:id')
patchPost() { }
}Parameter Decorators
Extract data from requests using parameter decorators:
@Body(schema?)
Inject request body with automatic Content-Type detection (JSON or form-urlencoded), optionally with Zod validation:
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(2),
email: z.string().email()
})
@Post('/users')
createUser(@Body(CreateUserSchema) body: CreateUserDto) {
// body is automatically parsed from JSON or form-urlencoded
// and validated against the schema
return Response.json(body)
}Supported content types:
application/json- JSON bodiesapplication/x-www-form-urlencoded- Form-encoded bodies
Both are automatically detected based on the Content-Type header.
@Query(key?)
Extract query parameters:
@Get('/search')
search(@Query('q') query: string, @Query('page') page: string) {
return Response.json({ query, page })
}@Route(key?)
Extract route parameters:
@Get('/users/:userId/posts/:postId')
getPost(
@Route('userId') userId: string,
@Route('postId') postId: string
) {
return Response.json({ userId, postId })
}@Req()
Inject the NextRequest object:
import { NextRequest } from 'next/server'
@Get('/info')
getInfo(@Req() request: NextRequest) {
return Response.json({ url: request.url })
}@Headers(key?)
Extract request headers:
@Get('/auth-info')
getAuthInfo(@Header('authorization') auth: string) {
return Response.json({ auth })
}@Context()
Inject the full request context:
import { RequestContext } from 'next-controllers'
@Get('/context')
getContext(@Context() ctx: RequestContext) {
return Response.json({
auth: ctx.auth,
params: ctx.params
})
}Authentication
JWT Authentication
Provide your own token verification function (e.g. using jose):
import { createJwtAuthProvider } from 'next-controllers'
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
export const { GET, POST, PUT, DELETE } = createNextHandler({
controllers: [UserController],
authProvider: createJwtAuthProvider({
verifyToken: async (token) => {
const { payload } = await jwtVerify(token, secret)
return payload as Record<string, unknown>
},
cookieName: 'token',
extractUser: (payload) => ({
userId: String(payload.sub),
roles: Array.isArray(payload.roles) ? payload.roles : [],
permissions: Array.isArray(payload.permissions) ? payload.permissions : undefined
})
})
})Session Authentication
import { createSessionAuthProvider } from 'next-controllers'
export const { GET, POST } = createNextHandler({
controllers: [UserController],
authProvider: createSessionAuthProvider({
cookieName: 'session',
getSession: async (sessionId) => {
// Fetch session from database
const session = await db.session.findUnique({ where: { id: sessionId } })
return {
userId: session.userId,
roles: session.roles
}
}
})
})Custom Authentication
import { createCustomAuthProvider } from 'next-controllers'
export const { GET, POST } = createNextHandler({
controllers: [UserController],
authProvider: createCustomAuthProvider(async (request) => {
const apiKey = request.headers.get('x-api-key')
if (!apiKey) return null
// Validate API key
const user = await validateApiKey(apiKey)
return {
userId: user.id,
roles: user.roles
}
})
})Authorization
Basic Authorization
Require authentication (returns 401 if not authenticated):
@Get('/profile')
@Authorize() // Enforces authentication - no roles required
getProfile(@Context() ctx: RequestContext) {
return Response.json({ user: ctx.auth })
}Role-Based Authorization
Require specific roles:
@Delete('/users/:id')
@Authorize('admin')
deleteUser(@Route('id') id: string) {
return Response.json({ deleted: true })
}Multiple roles (any of them grants access):
@Get('/content')
@Authorize('editor', 'admin')
getContent() {
return Response.json({ content: '...' })
}Controller-Level Authorization
Apply authorization to all routes in a controller:
@Controller('/admin')
@Authorize('admin')
export class AdminController {
@Get('/users')
getUsers() { }
@Get('/settings')
getSettings() { }
}Guards
Guards provide custom authorization logic:
import { Guard, RequestContext } from 'next-controllers'
class PremiumUserGuard implements Guard {
async canActivate(context: RequestContext): Promise<boolean> {
if (!context.auth) return false
// Check if user has premium subscription
const user = await db.user.findUnique({
where: { id: context.auth.userId }
})
return user?.isPremium === true
}
}
@Get('/premium-content')
@UseGuard(PremiumUserGuard)
getPremiumContent() {
return Response.json({ content: 'Premium content' })
}Built-in Guards
import { RoleGuard, PermissionGuard, AuthenticatedGuard } from 'next-controllers'
@Get('/admin')
@UseGuard(new RoleGuard(['admin']))
getAdmin() { }
@Get('/write')
@UseGuard(new PermissionGuard(['posts:write']))
writePost() { }
@Get('/protected')
@UseGuard(AuthenticatedGuard)
getProtected() { }Middleware
Middleware can intercept requests and responses:
import { Middleware, RequestContext } from 'next-controllers'
class LoggerMiddleware implements Middleware {
async run(context: RequestContext, next: () => Promise<Response>) {
console.log(`[${new Date().toISOString()}] ${context.request.method} ${context.request.url}`)
const start = Date.now()
const response = await next()
const duration = Date.now() - start
console.log(`Completed in ${duration}ms`)
return response
}
}
@Get('/data')
@Use(LoggerMiddleware)
getData() {
return Response.json({ data: '...' })
}Controller-Level Middleware
@Controller('/api')
@Use(LoggerMiddleware, CorsMiddleware)
export class ApiController {
// All routes will use these middleware
}Dependency Injection
Inject services into controllers:
// services/user.service.ts
export class UserService {
async findAll() {
return await db.user.findMany()
}
async findById(id: string) {
return await db.user.findUnique({ where: { id } })
}
}
// controllers/user.controller.ts
import { globalContainer } from 'next-controllers'
@Controller('/users')
export class UserController {
constructor(private userService: UserService) {}
@Get('/')
async getUsers() {
const users = await this.userService.findAll()
return Response.json(users)
}
}
// Register service
globalContainer.register(UserService)Configuration
Route Prefix
Add a prefix to all routes:
export const { GET, POST } = createNextHandler({
controllers: [UserController],
prefix: '/api/v1' // All routes will be prefixed with /api/v1
})Exception Handling
The library provides a built-in exception system for clean, structured error responses.
HttpException
Throw typed exceptions from your controllers instead of manually building error responses:
import {
HttpException,
NotFoundException,
BadRequestException,
ForbiddenException,
ConflictException,
} from 'next-controllers'
@Get('/users/:id')
async getUser(@Route('id') id: string) {
const user = await this.userService.findById(id)
if (!user) {
throw new NotFoundException('User not found')
}
return Response.json(user)
}
@Post('/users')
async createUser(@Body(CreateUserSchema) body: CreateUserDto) {
const existing = await this.userService.findByEmail(body.email)
if (existing) {
throw new ConflictException('Email already in use')
}
// ...
}Available exception classes:
BadRequestException(400)UnauthorizedException(401)ForbiddenException(403)NotFoundException(404)ConflictException(409)InternalServerErrorException(500)HttpException(custom status code)
Custom Exception Filter
Create your own exception filter for custom error formatting, logging, or monitoring:
import { ExceptionFilter, HttpException } from 'next-controllers'
import type { NextRequest } from 'next/server'
class MyExceptionFilter implements ExceptionFilter {
async catch(error: Error, request: NextRequest): Promise<Response> {
// Log to your monitoring service
await logToSentry(error)
if (error instanceof HttpException) {
return Response.json(
{ error: error.message, code: error.statusCode },
{ status: error.statusCode }
)
}
return Response.json(
{ error: 'Something went wrong' },
{ status: 500 }
)
}
}
export const { GET, POST } = createNextHandler({
controllers: [UserController],
exceptionFilter: new MyExceptionFilter(),
})Default Exception Filter
If you don't provide a custom exceptionFilter, the built-in DefaultExceptionFilter handles errors automatically:
HttpException- Returns the exception's status code and message as JSONZodError(validation failures) - Returns 400 with structured validation errors- Body parse errors - Returns 400 with "Invalid request body"
- Unknown errors - Returns 500 with "Internal Server Error" (no internal details leaked)
Legacy onError (deprecated)
The onError callback is still supported for backwards compatibility but exceptionFilter is the preferred approach. If both are provided, onError takes priority:
export const { GET, POST } = createNextHandler({
controllers: [UserController],
// @deprecated - use exceptionFilter instead
onError: (error, request) => {
console.error('API Error:', error)
return Response.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
}
})Folder Structure
Recommended project structure:
app/
api/
[...all]/
route.ts # Next.js catch-all handler
controllers/
user.controller.ts
auth.controller.ts
product.controller.ts
services/
user.service.ts
auth.service.ts
guards/
premium.guard.ts
admin.guard.ts
middleware/
logger.middleware.ts
cors.middleware.ts
dtos/
user.dto.ts
product.dto.tsBest Practices
1. Use DTOs with Zod
Define and validate your data structures:
import { z } from 'zod'
export const CreateProductSchema = z.object({
name: z.string().min(2).max(100),
price: z.number().positive(),
description: z.string().optional()
})
export type CreateProductDto = z.infer<typeof CreateProductSchema>
@Post('/products')
createProduct(@Body(CreateProductSchema) body: CreateProductDto) {
// body is fully validated
}2. Separate Business Logic
Keep controllers thin, move logic to services:
@Controller('/users')
export class UserController {
constructor(private userService: UserService) {}
@Get('/')
async getUsers() {
const users = await this.userService.findAll()
return Response.json(users)
}
}3. Use Guards for Reusable Auth Logic
class OwnerGuard implements Guard {
async canActivate(context: RequestContext): Promise<boolean> {
const resourceId = context.params.id
const userId = context.auth?.userId
return await checkOwnership(resourceId, userId)
}
}4. Handle Errors with HttpException
Throw typed exceptions instead of manually building error responses:
import { NotFoundException } from 'next-controllers'
@Get('/users/:id')
async getUser(@Route('id') id: string) {
const user = await this.userService.findById(id)
if (!user) {
throw new NotFoundException('User not found')
}
return Response.json(user)
}The DefaultExceptionFilter (or your custom filter) converts these into proper JSON responses automatically.
Performance
- Route Compilation: All routes are compiled once at application startup, not on every request
- Zero Runtime Overhead: Decorators are processed at startup, no reflection on each request
- Efficient Matching: Routes are sorted by specificity for optimal matching
- Minimal Dependencies: Only
path-to-regexpis required
TypeScript Support
Full TypeScript support with strict typing:
import type { RequestContext, AuthContext } from 'next-controllers'
@Controller('/api')
export class ApiController {
@Get('/context')
getContext(@Context() ctx: RequestContext) {
// ctx is fully typed
const userId: string | undefined = ctx.auth?.userId
const roles: string[] = ctx.auth?.roles || []
}
}Examples
See the examples directory for complete examples:
- User Controller - CRUD operations
- Auth Controller - Authentication
- Health Controller - Health checks
- Next.js Integration - App Router setup
Roadmap
- [ ] OpenAPI/Swagger generation
- [ ] Automatic controller discovery
- [ ] WebSocket support
- [ ] GraphQL integration
- [ ] More built-in validators
- [ ] Rate limiting middleware
- [ ] Response caching
- [ ] Request/Response interceptors
- [ ] Better error reporting in development
Contributing
Contributions are welcome! Please feel free to open issues or submit pull requests.
License
MIT © Aron Kalo
Acknowledgments
Inspired by:
