@codecraftkit/fetch
v0.4.0
Published
Native fetch-based HTTP client for Next.js (Data Cache aware) with axios-style interceptors, multipart/FormData support, dual call/request API, injectable token and logger.
Readme
@codecraftkit/fetch
Native fetch-based HTTP client for Next.js App Router. Forwards Data Cache options (
cache,next: { revalidate, tags }) andAbortSignalper call. Pluggable token resolver and logger. Zero runtime dependencies.
Install
npm install @codecraftkit/fetchRequires Node >=18 (uses global fetch).
Usage
Server components (App Router)
import 'server-only'
import { cookies } from 'next/headers'
import { createFetchClient } from '@codecraftkit/fetch'
export const api = createFetchClient({
baseUrl: process.env.API_URL,
secret: process.env.API_SECRET,
getToken: async () => (await cookies()).get('token')?.value
})
// Forward Data Cache controls per call
export const getBrokers = (q) => api.call(`/brokers?${q}`, null, { cache: 'no-store' })
export const getStats = (q) => api.call(`/stats?${q}`, null, { next: { revalidate: 60, tags: ['stats'] } })Client components
'use client'
import { createFetchClient } from '@codecraftkit/fetch'
const api = createFetchClient({
baseUrl: process.env.NEXT_PUBLIC_API_URL,
getToken: () => document.cookie.match(/token=([^;]+)/)?.[1]
})Options
| Option | Type | Default | Description |
|---|---|---|---|
| baseUrl | string | required | Prepended to every path passed to call(). |
| secret | string | — | Sent as x-secret header on every request. |
| getToken | () => string \| Promise<string> | — | Resolves the auth token per request. Called on each call(). |
| headersType | 'x-token' \| 'bearer' | 'x-token' | How to attach the token. bearer sets Authorization: Bearer <token>. |
| defaultHeaders | object | {} | Extra headers merged into every request. |
| logger | { debug, error } | no-op | Inject console to enable debug logs. |
Per-call config
api.call(path, data, {
method: 'POST', // default GET
headers: { ... }, // merged on top of defaults
cache: 'no-store', // Next.js Data Cache
next: { revalidate, tags },
signal: abortController.signal,
asBlob: false // force blob() regardless of content-type
})Verb shortcuts:
api.get(path, config)
api.post(path, data, config)
api.put(path, data, config)
api.delete(path, data, config)call vs request
The client exposes two entry points sharing the same interceptor pipeline:
| Method | Returns | Use when |
|--------|---------|----------|
| api.call(path, data, config) (and verb shortcuts) | parsed body only | You only need the response data (95% of cases) |
| api.request(path, data, config) | { data, status, headers, response, config } | You need status code or response headers (pagination, ETag, rate limits) |
// Common case
const users = await api.get('/users')
// Need pagination headers
const { data: users, headers } = await api.request('/users')
const total = headers.get('x-total-count')
// Need status code
const { data, status } = await api.request('/users', { id: 1 }, { method: 'POST' })
if (status === 201) toast.success('Created')Both methods run request and response interceptors identically. The only difference is whether the final result is unwrapped to .data (call) or left as the full shape (request).
Response handling
application/json→ parsed objectapplication/pdf,application/octet-stream,xlsx→BlobasBlob: true→Blobregardless of content-type- Non-2xx → throws
Errorwith message from JSONmessage/erroror text body
Sending raw bodies (multipart, blob, url-encoded, binary)
The client auto-detects raw body types and skips JSON serialization. The default Content-Type: application/json is stripped so the runtime can set the correct header (with boundary for multipart, blob type for Blob, etc.).
Multipart FormData
const form = new FormData()
form.append('name', 'doc.pdf')
form.append('file', fileBlob, 'doc.pdf')
await api.post('/upload', form)
// → Content-Type: multipart/form-data; boundary=... (set by runtime)Never set Content-Type manually for FormData — the boundary is auto-generated and a manual header breaks parsing on the server. The library strips it even if you try to set it.
Blob / File
const blob = new Blob([data], { type: 'image/png' })
await api.post('/upload', blob)
// → Content-Type: image/png (from blob.type)
// Override:
await api.post('/upload', blob, { headers: { 'Content-Type': 'application/octet-stream' } })URLSearchParams (form-urlencoded)
await api.post('/form', new URLSearchParams({ a: '1', b: '2' }))
// → Content-Type: application/x-www-form-urlencoded;charset=UTF-8ArrayBuffer / TypedArray / ReadableStream
Pass-through without serialization. You must set Content-Type explicitly — the runtime cannot infer it.
await api.post('/binary', new Uint8Array([1, 2, 3]), {
headers: { 'Content-Type': 'application/octet-stream' }
})Supported raw types
| Type | Content-Type behavior |
|------|----------------------|
| FormData | Always stripped (runtime sets multipart + boundary) |
| Blob / File | Default JSON stripped; runtime uses blob.type; user override wins |
| URLSearchParams | Default JSON stripped; runtime sets form-urlencoded |
| ArrayBuffer, TypedArray | Default JSON stripped; user must set Content-Type |
| ReadableStream | Default JSON stripped; user must set Content-Type |
| string | Default JSON kept (assumes JSON string) |
| plain object / array | JSON.stringify + Content-Type: application/json |
Interceptors
Axios-compatible API. Use interceptors.request.use to mutate the outgoing request and interceptors.response.use to transform the response or recover from errors. Both return an id for eject().
Request interceptor
const api = createFetchClient({ baseUrl: process.env.NEXT_PUBLIC_API_URL })
api.interceptors.request.use(async (config) => {
try {
const raw = window.localStorage.getItem('organization-tokens')
const store = raw ? JSON.parse(raw) : null
if (store) {
const state = store.state
config.headers['x-organization-id'] = state?.organizationToken
config.headers['x-organization-app-integration-id'] = state?.organizationAppIntegrationToken
}
} catch (e) {}
return config
})The config object exposes url, method, headers, data, cache, next, signal, asBlob. Mutate and return it, or return a new object. Interceptors run after getToken, so they can override the auth header.
Response interceptor
Handlers receive a wrapped object { data, status, headers, response, config } (axios-shaped). Mutate data to transform what the caller receives. The library returns result.data after the chain runs.
api.interceptors.response.use(
(res) => {
res.data = res.data.payload // unwrap envelope
return res
},
(error) => { throw error }
)Error recovery and refresh-token retry
The error handler receives an Error augmented with status, response, and config. Returning a value recovers the call; throwing forwards to the next error handler.
api.interceptors.response.use(null, async (error) => {
if (error.status !== 401) throw error
await refreshToken()
return api.call(error.config.url.replace(baseUrl, ''), error.config.data, {
method: error.config.method
})
})Eject
const id = api.interceptors.request.use(fn)
api.interceptors.request.eject(id)Why this and not axios?
Native fetch integrates with the Next.js Data Cache. Axios does not. Forwarding cache, next, and signal lets each caller opt into caching strategies without monkey-patching the client.
License
MIT
