Nuda Kit uses a controller-per-action pattern where each HTTP action has its own dedicated controller. Routes are organized by domain and automatically documented with Swagger.
The backend comes with adonis-autoswagger pre-configured. This generates interactive API documentation from your controller comments.
| URL | Description |
|---|---|
http://localhost:3333/docs | Swagger UI (interactive) |
http://localhost:3333/swagger | Raw OpenAPI JSON |
SWAGGER_USERNAME and SWAGGER_PASSWORD in your .env file.Routes are split by domain in start/routes/:
start/routes/
├── users.ts # Authentication, profile
├── teams.ts # Team management
├── invitations.ts # Invitation handling
├── billing.ts # Stripe integration
├── plans.ts # Subscription plans
├── ai.ts # AI endpoints
├── webhooks.ts # External webhooks
└── docs.ts # Swagger documentation
Routes use lazy-loaded controller imports for better performance:
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'
// Lazy-load controllers
const CreateTeamController = () => import('#controllers/teams/create_team_controller')
const GetAllTeamsController = () => import('#controllers/teams/get_all_teams_controller')
router
.group(() => {
router.post('/', [CreateTeamController, 'execute'])
router.get('/', [GetAllTeamsController, 'execute'])
})
.prefix('/teams')
.middleware(middleware.auth({ guards: ['api'] }))
Each controller handles exactly one action with an execute method:
// app/controllers/teams/create_team_controller.ts
export default class CreateTeamController {
public async execute({ request, response, auth }: HttpContext) {
const user = auth.user!
const payload = await request.validateUsing(createTeamValidator)
const team = await Team.create({ name: payload.name })
return response.created({
message: 'team created',
data: { team: team.serialize() },
})
}
}
Single Responsibility
Easy Testing
Better Navigation
Smaller Files
Document your controllers with JSDoc comments. The annotations are automatically parsed to generate OpenAPI documentation.
export default class CreateTeamController {
/**
* @execute
* @tag Teams
* @summary Creates a new team
* @description Detailed description of what this endpoint does
* @requestBody <createTeamValidator>
* @responseBody 201 - {"message": "team created", "data": {"team": "<Team>"}}
* @responseBody 401 - {errors: [{"message": "Unauthorized access"}]}
* @responseBody 422 - {"errors": [{"message": string, "rule": string, "field": string}]}
*/
public async execute({ request, response, auth }: HttpContext) {
// ...
}
}
| Annotation | Purpose |
|---|---|
@tag | Groups endpoints in Swagger UI |
@summary | Short description (shown in endpoint list) |
@description | Detailed description |
@requestBody | Request body schema (reference validator) |
@responseBody | Response format for status code |
@paramPath | Path parameter description |
@paramQuery | Query parameter description |
Use <validatorName> to reference VineJS validators:
/**
* @requestBody <createTeamValidator>
*/
The validator schema is automatically converted to OpenAPI format.
Use <ModelName> to reference Lucid models:
/**
* @responseBody 200 - {"data": {"team": "<Team>"}}
*/
/**
* @paramPath id - The team ID - @type(number) @required
*/
/**
* @paramQuery page - Page number - @type(number)
* @paramQuery limit - Items per page - @type(number)
*/
Group routes with common middleware or prefixes:
router
.group(() => {
// All routes in this group require authentication
router.get('/me', [GetAuthenticatedUserController, 'execute'])
router.put('/', [UpdateUserDetailController, 'execute'])
router.delete('/', [DeleteUserController, 'execute'])
})
.prefix('/users')
.middleware(middleware.auth({ guards: ['api'] }))
Name routes for URL generation:
router
.get('/login/magic-link/:email/verify', [VerifyUserMagicLinkController, 'execute'])
.as('users.login_user_magic_link_verify')
Use in your code:
import router from '@adonisjs/core/services/router'
const url = router.builder()
.params({ email: 'user@example.com' })
.makeSigned('users.login_user_magic_link_verify', { expiresIn: '5 minutes' })
Nuda Kit follows consistent response formats:
// 200 OK - Read operations
return response.ok({
message: 'teams retrieved',
data: { teams: teams.map(t => t.serialize()) },
})
// 201 Created - Create operations
return response.created({
message: 'team created',
data: { team: team.serialize() },
})
// 204 No Content - Delete operations
return response.noContent()
// 401 Unauthorized
return response.unauthorized({
errors: [{ message: 'Unauthorized access' }],
})
// 403 Forbidden
return response.forbidden({
errors: [{ message: 'You do not have permission' }],
})
// 404 Not Found
return response.notFound({
errors: [{ message: 'Team not found' }],
})
// 422 Validation Error (automatic from VineJS)
{
errors: [
{ message: 'The name field is required', rule: 'required', field: 'name' }
]
}
Create a new file in the appropriate domain folder:
// app/controllers/teams/archive_team_controller.ts
export default class ArchiveTeamController {
/**
* @execute
* @tag Teams
* @summary Archive a team
* @paramPath id - Team ID - @type(number) @required
* @responseBody 200 - {"message": "team archived"}
* @responseBody 404 - {"errors": [{"message": "Team not found"}]}
*/
public async execute({ params, response }: HttpContext) {
const team = await Team.findOrFail(params.id)
await team.merge({ archived: true }).save()
return response.ok({ message: 'team archived' })
}
}
Register in the appropriate route file:
// start/routes/teams.ts
const ArchiveTeamController = () => import('#controllers/teams/archive_team_controller')
router.post('/:id/archive', [ArchiveTeamController, 'execute'])
Visit http://localhost:3333/docs to see your new endpoint documented automatically.