Live demo — Launchstock · API Starter — Express + TypeScript
Static docs preview — the package ships the full runnable source
stackmesa · developer tools

The REST API you keep
rebuilding, done properly.

Express 4 + strict TypeScript boilerplate with JWT auth (refresh-token rotation and reuse detection), zod validation, a typed error envelope, rate limiting and structured logs — plus a full example resource wired end to end.

TypeScript strict JWT access + refresh zod-validated I/O Zero DB to boot Layered: routes → services
terminal — api-starter-ts
npm run dev {"level":"info","message":"server started","port":3000,"environment":"development"} curl localhost:3000/health {"status":"ok","uptimeSeconds":4,"environment":"development"} {"level":"info","message":"request completed", "requestId":"9f2c6d1e…","method":"GET","path":"/health", "status":200,"durationMs":1.8}
11REST endpoints, wired end to end
22TypeScript source files, every import resolves
100%strict mode + noUncheckedIndexedAccess
0external services needed to run it
Surface

Every endpoint in the box

Versioned under /api/v1. All requests get a correlation id; all errors share one typed envelope; credential routes sit behind a stricter rate limit.

MethodPathAuthDescription
GET/healthPublicLiveness probe — unversioned, never rate-limited
POST/api/v1/auth/registerPublicCreate account → 201 with user + token pair
POST/api/v1/auth/loginPublicCredentials → token pair (timing-safe on unknown emails)
POST/api/v1/auth/refreshPublicRotate a single-use refresh token → new pair
POST/api/v1/auth/logoutPublicRevoke a refresh token server-side → 204
GET/api/v1/auth/meBearerCurrent user profile
GET/api/v1/projectsBearerList own projects — pagination, status filter, sorting
POST/api/v1/projectsBearerCreate a project → 201
GET/api/v1/projects/:idBearerFetch one — someone else's id looks like 404, never 403
PATCH/api/v1/projects/:idBearerPartial update — name, description, status
DELETE/api/v1/projects/:idBearerDelete own project → 204
Security

Auth flow: rotation, not just refresh

Refresh tokens are single-use. Every refresh consumes the old token and issues a new pair — and replaying a consumed token revokes every session for that user.

Client (web / mobile) API (Express) POST /api/v1/auth/login { email, password } 200 · accessToken (15 min) + refreshToken (30 d, jti #1 stored) GET /api/v1/projects · Authorization: Bearer <access> 200 · { data, meta } 15 minutes later — the access token has expired GET /api/v1/projects · Bearer <expired access> 401 · error.code = "TOKEN_EXPIRED" POST /api/v1/auth/refresh { refreshToken #1 } 200 · new pair issued — jti #1 consumed, jti #2 stored (rotation) attacker replays consumed refreshToken #1 401 TOKEN_INVALID · reuse detected → ALL sessions for the user revoked

Single-use refresh tokens

Each refresh token carries a unique jti tracked server-side. Refreshing consumes it and issues a fresh pair — a leaked old token is worthless.

Reuse = theft

Presenting an already-consumed refresh token nukes every active session for that account. The real user logs in again; the attacker holds dead tokens.

Timing-safe login

bcrypt (cost 12) for password hashes, and unknown emails burn the same hash time as wrong passwords — response timing never leaks who has an account.

From the shipped source

What working with it looks like

Real requests against the running starter, and a real file from the package — not pseudocode.

# register an account — returns the user and a token pair
curl -X POST http://localhost:3000/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email":    "ada@example.com",
    "password": "correct-horse-battery",
    "name":     "Ada Lovelace"
  }'

# then hit a protected endpoint with the accessToken
curl http://localhost:3000/api/v1/projects \
  -H "Authorization: Bearer <accessToken>"
{
  "user": {
    "id": "7a1e2c9b-4f6d-4b8a-b3e1-0c5d9f2a6e83",
    "email": "ada@example.com",
    "name": "Ada Lovelace",
    "createdAt": "2026-07-10T09:14:31.882Z"
  },
  "tokens": {
    "tokenType": "Bearer",
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
    "accessTokenExpiresInSeconds": 900,
    "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
    "refreshTokenExpiresInSeconds": 2592000
  }
}
// POST /api/v1/auth/register with a bad email + short password
// → HTTP 422, every field issue in one round trip
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": [
      { "location": "body", "path": "email", "message": "A valid email address is required" },
      { "location": "body", "path": "password", "message": "Password must be at least 10 characters" }
    ]
  },
  "requestId": "9f2c6d1e-8b4a-4f0e-9d3c-2a7b5e8c1f4d"
}
// src/middleware/validate.ts — shipped verbatim in the package
export function validate(schemas: ValidationSchemas) {
  return (req: Request, _res: Response, next: NextFunction): void => {
    const issues: ValidationIssue[] = [];

    for (const location of LOCATIONS) {
      const schema = schemas[location];
      if (!schema) continue;

      const result = schema.safeParse(req[location]);
      if (result.success) {
        // replace raw input with parsed output:
        // coerced numbers, trimmed strings, defaults applied
        req[location] = result.data;
      } else {
        for (const issue of result.error.issues) {
          issues.push({ location, path: issue.path.join("."), message: issue.message });
        }
      }
    }

    if (issues.length > 0) {
      next(AppError.validation(issues));
      return;
    }
    next();
  };
}
Structure

Layered so it stays maintainable

Controllers never touch storage; services never touch req/res. Adding a resource is four files and one mount line — the projects resource is the worked template.

1Routeroutes/projects.routes.ts — paths, auth guard, middleware wiring
2Rate limit + authfixed-window limiter · requireAuth verifies the Bearer token, attaches req.user
3Validatevalidate({ body, query, params }) — zod parses, coerces and defaults the input
4ControllerHTTP ↔ service translation only — no business rules
5Servicebusiness rules + ownership checks + storage (the seam where your database plugs in)
Typed errors, alwaysanything thrown lands in one central handler → the same JSON envelope, logged with a request id
src/
├── app.ts              express assembly, middleware order
├── server.ts           boot + graceful shutdown
├── config.ts           zod-validated env config
├── lib/
│   ├── errors.ts       AppError + typed codes
│   ├── jwt.ts          sign/verify access + refresh
│   ├── logger.ts       JSON-lines structured logs
│   └── async-handler.ts
├── middleware/
│   ├── auth.ts         requireAuth bearer guard
│   ├── validate.ts     zod body/query/params
│   ├── error.ts        404 + central handler
│   ├── rate-limit.ts   fixed-window limiter
│   └── request-logger.ts
├── routes/             auth · projects · index
├── controllers/        HTTP layer
├── services/           business rules + stores
├── schemas/            zod + inferred types
└── types/express.d.ts  req.user augmentation
In the box

What you download

22 TypeScript source filesstrict mode, every import resolves, zero stubs
JWT auth, done rightaccess + refresh, rotation, reuse detection, bcrypt
Projects CRUD examplepagination, filtering, sorting, ownership scoping
zod everywhereenv config, bodies, queries and params — all validated
Typed error envelopestable machine-readable codes + request ids
README + full API referencesetup, auth flow, database-swap guide, every endpoint documented

Not included — honestly

  • No database — typed in-memory stores, with a mapped guide to swap in Postgres/Prisma or Mongo
  • No Docker files or CI pipeline
  • No automated test suite (the layering keeps services unit-testable)
  • No OAuth / social login — email + password JWT only