Organizing Routes in a Real Vapor App
When you follow a Vapor tutorial, you’ll often end up with something like this in routes.swift:
app.get("hello") { req in "Hello, world!" }
app.post("users") { req in ... }
app.get("users", ":id") { req in ... }
This is fine for a sample project. But a real app has dozens of routes at different authentication levels, multiple resources, and middleware that needs to apply selectively. If you keep going down this path, routes.swift becomes a sprawling list that’s hard to navigate and harder to reason about.
Here’s how I organize routes in Setlist HQ, a Vapor app I’ve been building.
RouteCollection: Each Controller Owns Its Routes
Vapor has a RouteCollection protocol that’s easy to overlook in the early stages of building an app. It’s just a protocol with one method:
protocol RouteCollection {
func boot(routes: RoutesBuilder) throws
}
Each controller in Setlist HQ conforms to this protocol. The controller defines its own routes, its own middleware, and its own path prefix — all in one place.
Here’s a simplified version of BandController:
struct BandController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let bands = routes.grouped("api", "v1", "bands")
let protected = bands
.grouped(CookieJWTAuthenticator())
.grouped(JWTAuthenticator())
.grouped(User.guardMiddleware())
protected.post(use: create)
protected.get(use: index)
protected.get(":bandID", use: show)
protected.put(":bandID", use: update)
protected.get(":bandID", "members", use: listMembers)
protected.post(":bandID", "members", use: addMember)
protected.put(":bandID", "members", ":userID", use: updateMemberRole)
protected.delete(":bandID", "members", ":userID", use: removeMember)
}
}
The main routes.swift file just registers each controller:
func routes(_ app: Application) throws {
app.get("health") { req async -> HTTPStatus in .ok }
try app.register(collection: AuthController())
try app.register(collection: BandController())
try app.register(collection: SongController())
try app.register(collection: GigController())
try app.register(collection: SetListController())
try app.register(collection: AdminController())
try app.register(collection: PublicController())
try app.register(collection: StripeWebhookController())
// ...and so on
}
This file is now declarative. You can see every controller at a glance without having to read a thousand lines of route definitions. The routing details live with the controller that cares about them.
Layering Middleware for Auth
Setlist HQ has a web client that uses cookies, and it will eventually have an API for mobile clients that use bearer tokens. Rather than create duplicate routes, I layer two authenticators and let them work in sequence.
let protected = bands
.grouped(CookieJWTAuthenticator()) // extracts JWT from the auth_token cookie
.grouped(JWTAuthenticator()) // extracts JWT from Authorization: Bearer
.grouped(User.guardMiddleware()) // requires a User to be present
CookieJWTAuthenticator checks the cookie first. JWTAuthenticator handles the bearer token. Each one checks request.auth.has(User.self) before doing any work, so they don’t step on each other:
struct JWTAuthenticator: AsyncBearerAuthenticator {
func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
// Don't re-authenticate if a previous middleware already did it
if request.auth.has(User.self) { return }
let payload = try await request.jwt.verify(bearer.token, as: UserToken.self)
// ...
}
}
This means the same endpoint works for both clients without any changes. User.guardMiddleware() at the end ensures that if neither authenticator succeeded, the request is rejected with a 401.
Tiering Access with Nested Groups
Not all routes need the same level of access. AuthController has a mix of public and protected endpoints:
struct AuthController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let auth = routes.grouped("api", "v1", "auth")
// Public — no auth required
auth.post("register", use: register)
auth.post("login", use: login)
auth.post("forgot-password", use: forgotPassword)
// Protected — requires authentication
let protected = auth
.grouped(CookieJWTAuthenticator())
.grouped(User.guardMiddleware())
protected.get("me", use: getCurrentUser)
protected.post("logout", use: logout)
}
}
AdminController goes one level further, adding an AdminMiddleware that checks for an admin role after authentication:
let authenticated = admin
.grouped(CookieJWTAuthenticator())
.grouped(User.guardMiddleware())
let adminOnly = authenticated
.grouped(AdminMiddleware())
adminOnly.get("users", use: listUsers)
adminOnly.post("users", ":userID", "impersonate", use: impersonateUser)
The nesting makes the hierarchy explicit. You read it top to bottom: first you need to be authenticated, then you need to be an admin.
Global Middleware vs Route-Level Middleware
Some middleware should run on every request regardless of route. In configure.swift, I register these globally:
app.middleware.use(CorrelationIDMiddleware()) // attaches a trace ID to every request
app.middleware.use(cors, at: .beginning) // CORS headers
app.middleware.use(ResponseLoggingMiddleware()) // logs response status and timing
app.middleware.use(EntitlementErrorMiddleware()) // converts billing errors to HTTP 403s
The distinction I find useful: middleware that transforms or observes requests without needing to know which route is being hit goes in configure.swift. Middleware that restricts access or provides auth context goes in the RouteCollection.
Public Routes Are Explicit
It’s easy to forget that a route without auth middleware is public. To make this intentional, I have a dedicated PublicController for routes that are meant to be unauthenticated:
struct PublicController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let pub = routes.grouped("api", "v1", "public")
pub.get(":slug", "songs", use: getPublicSongList)
pub.post("mailing-list", use: subscribeToMailingList)
}
}
Having a named controller for public routes makes it obvious at a glance which routes don’t require auth. It also means I’m never accidentally leaving a route open — if it’s not in PublicController and it doesn’t have auth middleware, something is wrong.
Wrapping Up
The pattern I’ve landed on:
routes.swiftis just a list ofapp.register(collection:)calls — declarative, easy to scan- Each controller owns its path prefix, middleware, and route handlers
- Auth is layered — cookie auth and bearer auth chain together, each idempotent
- Access levels are nested — public → authenticated → admin, each layer adding a middleware
- Global middleware lives in
configure.swift, route-specific middleware lives in the controller
None of this is groundbreaking, but it took me a few iterations to settle on it. Vapor gives you enough flexibility to do this in a lot of different ways, and it’s worth picking a convention early before routes.swift turns into a wall of closures.