GuidesAPI Development

Building REST APIs

Step-by-step guide to building a REST API with NextRush

Build a complete user management REST API with NextRush. You'll define routes, parse request bodies, throw typed errors, and return structured JSON responses.

What You Will Build

A CRUD API for managing users:

MethodPathDescription
GET/api/usersList all users
GET/api/users/:idGet user by ID
POST/api/usersCreate a user
PUT/api/users/:idUpdate a user
DELETE/api/users/:idDelete a user

Prerequisites

  • Node.js 22+
  • TypeScript 5.x with strict mode
$ pnpm add nextrush @nextrush/body-parser

Project Structure

src/
├── index.ts          # Application entry point
├── routes/
│   └── users.ts      # User routes
├── services/
│   └── user.ts       # Business logic
└── types/
    └── user.ts       # Type definitions

Step-by-Step

Define your types

Start with clear type definitions:

src/types/user.ts
export interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
  updatedAt: Date;
}

export interface CreateUserInput {
  name: string;
  email: string;
}

export interface UpdateUserInput {
  name?: string;
  email?: string;
}

Create a service layer

Separate business logic from HTTP handling:

src/services/user.ts
import type { User, CreateUserInput, UpdateUserInput } from '../types/user';

// In-memory store for demonstration
const users = new Map<string, User>();

export const userService = {
  findAll(): User[] {
    return Array.from(users.values());
  },

  findById(id: string): User | undefined {
    return users.get(id);
  },

  create(input: CreateUserInput): User {
    const id = crypto.randomUUID();
    const now = new Date();

    const user: User = {
      id,
      name: input.name,
      email: input.email,
      createdAt: now,
      updatedAt: now,
    };

    users.set(id, user);
    return user;
  },

  update(id: string, input: UpdateUserInput): User | undefined {
    const existing = users.get(id);
    if (!existing) return undefined;

    const updated: User = {
      ...existing,
      ...input,
      updatedAt: new Date(),
    };

    users.set(id, updated);
    return updated;
  },

  delete(id: string): boolean {
    return users.delete(id);
  },
};

Define routes

Register routes on the router with relative paths. The mount prefix is applied when you attach the router to the app.

src/routes/users.ts
import { createRouter, NotFoundError, BadRequestError } from 'nextrush';
import { userService } from '../services/user';
import type { CreateUserInput, UpdateUserInput } from '../types/user';

export const usersRouter = createRouter();

// GET /api/users
usersRouter.get('/', (ctx) => {
  const users = userService.findAll();
  ctx.json({
    data: users,
    total: users.length,
  });
});

// GET /api/users/:id
usersRouter.get('/:id', (ctx) => {
  const { id } = ctx.params;
  const user = userService.findById(id);

  if (!user) {
    throw new NotFoundError(`User with ID "${id}" not found`);
  }

  ctx.json({ data: user });
});

// POST /api/users
usersRouter.post('/', (ctx) => {
  const body = ctx.body as CreateUserInput;

  if (!body.name || !body.email) {
    throw new BadRequestError('Name and email are required');
  }

  const user = userService.create(body);

  ctx.status = 201;
  ctx.json({ data: user });
});

// PUT /api/users/:id
usersRouter.put('/:id', (ctx) => {
  const { id } = ctx.params;
  const body = ctx.body as UpdateUserInput;

  const user = userService.update(id, body);

  if (!user) {
    throw new NotFoundError(`User with ID "${id}" not found`);
  }

  ctx.json({ data: user });
});

// DELETE /api/users/:id
usersRouter.delete('/:id', (ctx) => {
  const { id } = ctx.params;
  const deleted = userService.delete(id);

  if (!deleted) {
    throw new NotFoundError(`User with ID "${id}" not found`);
  }

  ctx.status = 204;
  ctx.send('');
});

Router paths are relative to the mount point. When you call app.route('/api/users', usersRouter), a route registered as /:id matches requests to /api/users/:id.

Set up the application

Wire everything together:

src/index.ts
import { createApp, listen, errorHandler, notFoundHandler } from 'nextrush';
import { json } from '@nextrush/body-parser';
import { usersRouter } from './routes/users';

const app = createApp();

// Error handling (must be first)
app.use(errorHandler({
  includeStack: process.env.NODE_ENV !== 'production',
}));

// Parse JSON bodies
app.use(json());

// Mount routes
app.route('/api/users', usersRouter);

// 404 handler (must be last)
app.use(notFoundHandler());

// Start server
await listen(app, 8080);

Middleware order matters. The error handler wraps all downstream middleware, so it must be registered first. The 404 handler catches unmatched requests, so it must be last.

Verify your API

Start the server and test with curl:

# Create a user
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice", "email": "alice@example.com"}'

# List all users
curl http://localhost:8080/api/users

# Get a specific user (replace with a real ID from the create response)
curl http://localhost:8080/api/users/<id>

# Update a user
curl -X PUT http://localhost:8080/api/users/<id> \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Updated"}'

# Delete a user
curl -X DELETE http://localhost:8080/api/users/<id>

Expected responses:

  • POST returns 201 with the created user
  • GET returns 200 with { data: ... }
  • PUT returns 200 with the updated user
  • DELETE returns 204 with an empty body
  • Missing users return 404 with a structured error

Response Format

The routes above follow a consistent response shape — a data envelope for single resources, data + total/meta for lists, and a structured error body for failures:

// Single resource
{ "data": { "id": "1", "name": "Alice", "email": "alice@example.com" } }

// List
{ "data": [ /* ... */ ], "total": 2 }

// Error (thrown via a typed error class — see Error Handling)
{ "error": "NotFoundError", "message": "User not found", "code": "NOT_FOUND", "status": 404 }

@nextrush/errors exports one class per common status — NotFoundError, BadRequestError, UnauthorizedError, ForbiddenError, and ConflictError are the ones a CRUD API reaches for most.

Typed error classes

Throwing NotFoundError, BadRequestError, UnauthorizedError, ForbiddenError, or ConflictError from a handler is caught automatically and converted to the response shape above — no manual try/catch needed. See Error Handling for the full class list and custom error handling.

Adding Pagination

For large datasets, add query-based pagination to the list endpoint:

usersRouter.get('/', (ctx) => {
  const page = Number(ctx.query.page) || 1;
  const limit = Number(ctx.query.limit) || 10;

  const allUsers = userService.findAll();
  const start = (page - 1) * limit;
  const users = allUsers.slice(start, start + limit);

  ctx.json({
    data: users,
    meta: { total: allUsers.length, page, limit },
  });
});

Filter the list endpoint by a free-text query and an exact-match field:

usersRouter.get('/', (ctx) => {
  let result = userService.findAll();

  const search = ctx.query.q?.toLowerCase();
  if (search) {
    result = result.filter(
      (user) => user.name.toLowerCase().includes(search) || user.email.toLowerCase().includes(search)
    );
  }

  const name = ctx.query.name;
  if (name) {
    result = result.filter((user) => user.name === name);
  }

  ctx.json({ data: result, total: result.length });
});

Using Class-Based Controllers

For larger applications, you can use decorator-based controllers with dependency injection from nextrush/class — already included in the nextrush meta-package installed above, no additional packages required.

Full class-based controller implementation
src/controllers/users.controller.ts
import { Controller, Get, Post, Put, Delete, Body, Param, Service } from 'nextrush/class';
import { NotFoundError, BadRequestError } from 'nextrush';
import type { User, CreateUserInput, UpdateUserInput } from '../types/user';

@Service()
class UserService {
  private users = new Map<string, User>();

  findAll() {
    return Array.from(this.users.values());
  }

  findById(id: string) {
    return this.users.get(id);
  }

  create(input: CreateUserInput) {
    const user: User = {
      id: crypto.randomUUID(),
      ...input,
      createdAt: new Date(),
      updatedAt: new Date(),
    };
    this.users.set(user.id, user);
    return user;
  }

  update(id: string, input: UpdateUserInput) {
    const existing = this.users.get(id);
    if (!existing) return undefined;
    const updated = { ...existing, ...input, updatedAt: new Date() };
    this.users.set(id, updated);
    return updated;
  }

  delete(id: string) {
    return this.users.delete(id);
  }
}

@Controller('/users')
export class UsersController {
  constructor(private userService: UserService) {}

  @Get()
  findAll() {
    return { data: this.userService.findAll() };
  }

  @Get('/:id')
  findOne(@Param('id') id: string) {
    const user = this.userService.findById(id);
    if (!user) throw new NotFoundError('User not found');
    return { data: user };
  }

  @Post()
  create(@Body() input: CreateUserInput) {
    if (!input.name || !input.email) {
      throw new BadRequestError('Name and email are required');
    }
    return { data: this.userService.create(input) };
  }

  @Put('/:id')
  update(@Param('id') id: string, @Body() input: UpdateUserInput) {
    const user = this.userService.update(id, input);
    if (!user) throw new NotFoundError('User not found');
    return { data: user };
  }

  @Delete('/:id')
  remove(@Param('id') id: string) {
    if (!this.userService.delete(id)) {
      throw new NotFoundError('User not found');
    }
    return { success: true };
  }
}

Bootstrap with registerControllers — a registrar function you call once, awaited, before starting the server:

src/index.ts
import { createApp, listen } from 'nextrush';
import { registerControllers } from 'nextrush/class';
import { json } from '@nextrush/body-parser';

const app = createApp();

app.use(json());
await registerControllers(app, {
  root: './src',
  prefix: '/api',
});

await listen(app, 8080);

Common Patterns

Route Grouping

import { createRouter } from 'nextrush';

const users = createRouter();
users.get('/', listUsers);
users.get('/:id', getUser);

const posts = createRouter();
posts.get('/', listPosts);
posts.get('/:id', getPost);

app.route('/api/users', users);
app.route('/api/posts', posts);

Request Logging Middleware

app.use(async (ctx) => {
  const start = Date.now();
  await ctx.next();
  const duration = Date.now() - start;
  app.logger.info(`${ctx.method} ${ctx.path} ${ctx.status} - ${duration}ms`);
});

Content Negotiation

usersRouter.get('/:id', (ctx) => {
  const user = userService.findById(ctx.params.id);
  if (!user) throw new NotFoundError('User not found');

  const accept = ctx.get('accept') || 'application/json';

  if (accept.includes('text/html')) {
    ctx.html(`<h1>${user.name}</h1><p>${user.email}</p>`);
  } else {
    ctx.json({ data: user });
  }
});

What's Next?

Was this helpful?

On this page