Clean API Layer in Node.js with mvc-front-sdk

March 18, 2026

Clean API Layer in Node.js with mvc-front-sdk

Most Node.js projects end up with API calls scattered across files — fetch calls living in components, service files with no clear structure, and token management duplicated everywhere. mvc-front-sdk is a small library I built to fix that. It gives you a structured, type-safe controller pattern that works in any JavaScript environment, including plain Node.js.

Node.js and TypeScript code

Installation

npm install mvc-front-sdk # or pnpm add mvc-front-sdk

Requirements: Node.js >= 18, TypeScript ^5.

The problem it solves

Without structure, API code tends to look like this — spread across your project with no clear ownership:

// Scattered everywhere const res = await fetch(`${BASE_URL}/users/${id}`, { headers: { Authorization: `Bearer ${token}` }, }); const user = await res.json();

With mvc-front-sdk, you define a controller once and use it everywhere cleanly.

Setting up your first controller

Extend BaseController with your base URL and optional auth token. The constructor handles token injection into every request automatically.

// src/controllers/user.controller.ts import { BaseController } from "mvc-front-sdk"; type User = { id: string; name: string; email: string; }; type CreateUserDto = { name: string; email: string; }; export class UserController extends BaseController { constructor() { super( process.env.API_BASE_URL!, process.env.API_TOKEN, // Optional — adds Authorization: Bearer <token> to all requests ); } async getAll(filters?: { page?: number; limit?: number }) { const params = this.buildSearchParams(filters); const url = this.createURL("/users", params); return this.apiService.get<User[]>(url); } async getById(id: string) { return this.apiService.get<User>(`/users/${id}`); } async create(data: CreateUserDto) { return this.apiService.post<User>("/users", data); } async update(id: string, data: Partial<User>) { return this.apiService.put<User>(`/users/${id}`, data); } async remove(id: string) { return this.apiService.delete(`/users/${id}`); } }

Using it in a script or service

Instantiate the controller and call its methods. Everything is typed — no any, no manual header wiring.

// src/scripts/sync-users.ts import { UserController } from "../controllers/user.controller"; const users = new UserController(); async function run() { const list = await users.getAll({ page: 1, limit: 50 }); console.log(`Fetched ${list.length} users`); const newUser = await users.create({ name: "John Doe", email: "john.doe@example.com", }); console.log("Created:", newUser.id); } run();

Query parameters with buildSearchParams

The buildSearchParams helper converts a plain object into URLSearchParams, with support for renaming keys and custom transforms:

const params = this.buildSearchParams( { name: "Fariol", page: 1, tags: ["ts", "node"] }, { rename: { name: "fullName" }, // rename key before sending transform: { page: (v) => String(v) }, }, ); // → fullName=Fariol&page=1&tags=ts,node

Then compose the URL:

const url = this.createURL("/users", params); // → /users?fullName=Fariol&page=1&tags=ts,node

Default headers

If your API requires versioning headers or a client ID on every request, pass them once in the constructor:

super("https://api.example.com", process.env.API_TOKEN, { "X-API-Version": "v1", "X-Client-ID": "my-node-service", });

Headers passed directly to apiService.get(...) will override defaults for that specific call:

await this.apiService.get("/users", { "X-API-Version": "v2", // overrides v1 for this request only });

Error handling with ApiError

The SDK throws typed ApiError instances, so you can handle HTTP errors explicitly:

import { ApiError } from "mvc-front-sdk"; try { await users.getById("nonexistent-id"); } catch (error) { if (error instanceof ApiError) { console.error(error.statusCode); // e.g. 404 console.error(error.message); // error message console.error(error.isUnAuthenticated()); // true if 401 } }

Or delegate to the built-in handler inside your controller:

async getById(id: string) { try { return await this.apiService.get<User>(`/users/${id}`); } catch (error) { this.handleError(error); // re-throws with formatted message } }

Dynamic token refresh

If your token expires during a session (e.g. after a login/refresh flow), update it at runtime without recreating the controller:

export class AuthController extends BaseController { constructor() { super("https://api.example.com"); } async login(email: string, password: string) { const { token } = await this.apiService.post<{ token: string }>( "/auth/login", { email, password }, ); this.apiService.setToken(token); return token; } logout() { this.apiService.setToken(undefined); } }

FormData support

File uploads work out of the box — pass a FormData instance as the body:

async uploadAvatar(userId: string, file: File) { const form = new FormData(); form.append("file", file); form.append("userId", userId); return this.apiService.post<{ url: string }>("/upload", form); }

Project structure recommendation

src/ ├── controllers/ │ ├── user.controller.ts │ ├── auth.controller.ts │ └── product.controller.ts ├── scripts/ │ └── sync-users.ts └── index.ts

Keep one controller per resource. Each controller owns all the HTTP logic for that resource — nothing leaks out.

Source

The library is open source. You can find it on GitHub and install it from npm as mvc-front-sdk.

GitHub
LinkedIn