Split app.js From server.js and Keep Routes in Their Own Folder
Application Structure
A well-structured Koa app separates middleware wiring in app.js from the HTTP server in server.js, groups routes by feature, and uses ctx.state for per-request data shared across middleware.
What you'll learn
- Separate application wiring from server startup into two files
- Organise routes and middleware into a logical folder structure
- Use ctx.state effectively for data shared across middleware layers
Placing everything in a single file works for demos but becomes hard to maintain. A conventional Koa project separates concerns into predictable locations so any developer can orient themselves quickly.
Recommended File Layout
my-koa-app/
├── src/
│ ├── app.js # create and configure the Koa instance
│ ├── server.js # import app, call app.listen
│ ├── routes/
│ │ ├── users.js # user-related middleware / router
│ │ └── items.js
│ ├── middleware/
│ │ ├── error.js # top-level error handler
│ │ ├── logger.js
│ │ └── auth.js
│ └── config.js # env vars, constants
└── package.json
app.js — Middleware Wiring
app.js creates the Koa instance, registers all middleware, and exports the
app. It does not call app.listen.
// src/app.js
import Koa from "koa";
import { errorMiddleware } from "./middleware/error.js";
import { loggerMiddleware } from "./middleware/logger.js";
const app = new Koa();
app.proxy = true; // trust X-Forwarded-For behind a proxy
app.keys = ["secret"]; // for signed cookies
// Middleware — order matters!
app.use(errorMiddleware);
app.use(loggerMiddleware);
// Routes are added in server.js or here after import
export default app; server.js — Startup
server.js imports the configured app and starts listening. Keeping this
separate makes app.js easy to test with supertest or similar tools because
tests can import the app without binding to a port.
// src/server.js
import app from "./app.js";
const PORT = process.env.PORT ?? 3000;
app.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
}); ctx.state — Per-Request Shared Data
ctx.state is the right place to attach data that multiple middleware layers
need to read. Treat it like a per-request store:
// middleware/auth.js
export async function authMiddleware(ctx, next) {
const token = ctx.get("Authorization")?.replace("Bearer ", "");
if (token) {
ctx.state.user = await verifyToken(token); // available to all downstream
}
await next();
} // routes/users.js
export async function getProfile(ctx) {
ctx.assert(ctx.state.user, 401, "Not authenticated");
ctx.body = ctx.state.user;
} config.js — Centralise Environment Variables
// src/config.js
export const config = {
port: Number(process.env.PORT) || 3000,
jwtSecret: process.env.JWT_SECRET ?? "dev-secret",
nodeEnv: process.env.NODE_ENV ?? "development",
}; Centralising configuration in one module means you import config everywhere
rather than scattering process.env reads across the codebase.
Chapter 2 will show you how to add a proper router so each route file handles only its own paths.