Blog
Projects
Linkedin
Contact

Using the Builder Pattern to create API calls with a RequestBuilder in TypeScript

Jan 9, 2026

In modern applications — especially React / Next.js frontends and BFF (Backend for Frontend) architectures — the HTTP layer tends to grow with cross-cutting requirements: dynamic headers, authentication, token refresh, retries, timeouts, pagination, optional filters, idempotency, and observability.
The problem is not making a request. The problem is keeping code readable and evolvable when “a simple GET call” turns into a set of decisions.
In this article, I present a pragmatic approach using the Builder Pattern to build HTTP requests via a RequestBuilder (TypeScript), keeping a clear separation between:

The real problem

The most common anti-pattern I see is centralizing everything into a single function with a long list of optional parameters.
Anti-pattern
await httpRequest('users', 'GET', {
  query: { page: 1, search: undefined },
  headers: { 'x-source': 'web' },
  timeoutMs: 8000,
  retries: 3,
  retryDelayMs: 200,
  refreshTokenOn401: true,
  baseURLOverride: process.env.EXTERNAL_API,
})
It works, but it quickly degrades in three ways:

Builder Pattern (goal)

The Builder is a creational pattern that helps when you need to build a complex object step by step, with many option combinations and well-defined defaults.
Applied to HTTP requests, the complex object is a RequestDescriptor: URL, method, headers, query params, body, timeout, and execution metadata (retry, auth strategy, etc.).

When it makes sense

This is not “pattern for pattern’s sake”. It pays off when you have combinatorial complexity, not just volume.

Solution architecture (Builder vs executor)

The key architectural point is not coupling the Builder to Axios. The Builder expresses intent; the HTTP layer executes.

RequestBuilder structure

The base is a Builder with internal mutable state (expected for builders) and a fluent interface.
RequestBuilder.ts
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'

export type QueryValue = string | number | boolean | null | undefined
export type QueryParams = Record<string, QueryValue>

export type HeadersMap = Record<string, string>

export interface RequestDescriptor<TResponse = unknown> {
  method: HttpMethod
  url: string
  headers: HeadersMap
  query: QueryParams
  body?: unknown
  timeoutMs?: number

  // Execution metadata (not Axios options directly)
  retries?: number
  requireAuth?: boolean
}

export class RequestBuilder<TResponse = unknown> {
  private descriptor: RequestDescriptor<TResponse>

  constructor() {
    this.descriptor = {
      method: 'GET',
      url: '',
      headers: {},
      query: {},
      retries: 0,
      requireAuth: true,
    }
  }

  setUrl(url: string) {
    this.descriptor.url = url
    return this
  }

  setMethod(method: HttpMethod) {
    this.descriptor.method = method
    return this
  }

  addHeader(key: string, value: string) {
    this.descriptor.headers[key] = value
    return this
  }

  setBody(body: unknown) {
    this.descriptor.body = body
    return this
  }

  setTimeout(timeoutMs: number) {
    this.descriptor.timeoutMs = timeoutMs
    return this
  }

  setRetries(retries: number) {
    this.descriptor.retries = retries
    return this
  }

  requireAuth(requireAuth: boolean) {
    this.descriptor.requireAuth = requireAuth
    return this
  }

  addQueryParam(key: string, value: QueryValue) {
    this.descriptor.query[key] = value
    return this
  }

  addQueryParams(params: QueryParams) {
    Object.entries(params).forEach(([k, v]) => {
      this.addQueryParam(k, v)
    })
    return this
  }

  build(): RequestDescriptor<TResponse> {
    if (!this.descriptor.url) {
      throw new Error('RequestBuilder: url is required')
    }

    return {
      ...this.descriptor,
      headers: { ...this.descriptor.headers },
      query: { ...this.descriptor.query },
    }
  }
}
Notice `build()` returns an immutable object (copies of headers/query). This avoids side effects if the same builder is accidentally reused.

Query params (the sharp edge)

In real projects, query params are a constant source of bugs: `undefined` turning into a string, optional filters generating inconsistent URLs, and duplicated rules.
The rule I follow: the builder accepts `undefined`, but URL serialization must exclude `undefined` and `null`.
buildUrlWithQuery.ts
import { QueryParams } from './RequestBuilder'

export function buildUrlWithQuery(url: string, query: QueryParams): string {
  const entries = Object.entries(query).filter(([, v]) => v !== undefined && v !== null)

  if (entries.length === 0) return url

  const params = new URLSearchParams()
  for (const [k, v] of entries) {
    params.append(k, String(v))
  }

  const separator = url.includes('?') ? '&' : '?'
  return url + separator + params.toString()
}

Centralized HTTP executor

This is where Axios, interceptors and resilience policies live. The Builder knows nothing about them.
httpClient.ts (Axios + refresh + retry)
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'
import { RequestDescriptor } from './RequestBuilder'
import { buildUrlWithQuery } from './buildUrlWithQuery'

type TokenProvider = () => Promise<string | null>
type RefreshToken = () => Promise<void>

function isUnauthorized(error: unknown): boolean {
  return axios.isAxiosError(error) && error.response?.status === 401
}

async function withRetry<T>(
  fn: () => Promise<T>,
  retries: number,
  delayMs: number,
): Promise<T> {
  let attempt = 0
  // simple retry, usually enough for most BFF/frontends
  while (true) {
    try {
      return await fn()
    } catch (err) {
      attempt += 1
      if (attempt > retries) throw err
      await new Promise((r) => setTimeout(r, delayMs))
    }
  }
}

export class HttpClient {
  private axios: AxiosInstance
  private getToken: TokenProvider
  private refreshToken: RefreshToken

  constructor(params: {
    baseURL: string
    getToken: TokenProvider
    refreshToken: RefreshToken
  }) {
    this.getToken = params.getToken
    this.refreshToken = params.refreshToken

    this.axios = axios.create({
      baseURL: params.baseURL,
    })

    // Auth interceptor: inject token only when the request requires it
    this.axios.interceptors.request.use(async (config) => {
      const requireAuth = (config.headers as any)?.['x-require-auth'] === 'true'

      if (!requireAuth) return config

      const token = await this.getToken()
      if (token) {
        config.headers = {
          ...(config.headers || {}),
          Authorization: 'Bearer ' + token,
        }
      }

      return config
    })
  }

  async request<TResponse>(descriptor: RequestDescriptor<TResponse>): Promise<TResponse> {
    const url = buildUrlWithQuery(descriptor.url, descriptor.query)

    const config: AxiosRequestConfig = {
      url,
      method: descriptor.method,
      headers: {
        ...descriptor.headers,
        // internal flag, consumed by the interceptor
        'x-require-auth': descriptor.requireAuth ? 'true' : 'false',
      },
      data: descriptor.body,
      timeout: descriptor.timeoutMs,
    }

    const exec = async () => {
      try {
        const res = await this.axios.request<TResponse>(config)
        return res.data
      } catch (err) {
        if (isUnauthorized(err) && descriptor.requireAuth) {
          await this.refreshToken()
          const res = await this.axios.request<TResponse>(config)
          return res.data
        }

        throw err
      }
    }

    return withRetry(exec, descriptor.retries ?? 0, 200)
  }
}
This design makes the contract explicit:

Practical example (service getAll)

In services, the Builder improves readability because the code reads as intent, not as a bag of options.
usersService.ts
type User = { id: string; name: string }

type GetAllUsersQuery = {
  page?: number
  search?: string
  active?: boolean
}

export class UsersService {
  constructor(private http: HttpClient) {}

  async getAll(query: GetAllUsersQuery = {}): Promise<User[]> {
    const descriptor = new RequestBuilder<User[]>()
      .setUrl('/users')
      .setMethod('GET')
      .addQueryParams({
        page: query.page,
        search: query.search,
        active: query.active,
      })
      .setTimeout(8000)
      .setRetries(2)
      .build()

    return this.http.request(descriptor)
  }
}
The result is a service with low “accidental complexity”: it knows the endpoint domain, not the infrastructure.

Why this improves maintainability

Advantages (no marketing)

Drawbacks and watch-outs

When to use vs avoid

Use it when the product requires consistency and resilience across multiple integrations (especially with BFF and Next.js).
Avoid it when:

Conclusion

The Builder Pattern applied to requests is not about “making it look pretty”; it is about reducing accidental complexity and preventing execution details (Axios, retry, refresh) from contaminating the domain.
In projects that must scale safely — especially React/Next.js with a BFF layer — a well-designed `RequestBuilder` becomes an architectural stability point: the team evolves policies in the executor while keeping services predictable.
Useful references: Axios | Builder Pattern
If you found this guide helpful, feel free to share it and if you have any questions or comments just contact me. Your journey to better API calls starts here! 🚀
BlogProjectsGithubLinkedin
Built with Next.js, Tailwind and Vercel
Coded by me (Bruno Werner :P)