Application Structure

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.

4 min read Level 2/5 #koa#structure#app.js
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.

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.