npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nexfetch/rest

v0.4.0

Published

Framework-agnostic REST API client with cache, retry, dedup, and optional schema validation

Downloads

66

Readme

@nexfetch/rest

Typed REST client with endpoint-level typing, reactive cache, retry, GET deduplication, tag-based invalidation, pagination, and runtime schema validation.

  • Zero runtime dependencies
  • Works in React, vanilla JS, and any framework that can bridge a minimal Observable<T> contract
  • Cache and mutation semantics are explicit — no magic, no hidden behavior

Install

npm install @nexfetch/rest

Quick start

1. Define endpoints

import { defineEndpoints } from "@nexfetch/rest";
import { z } from "zod";

const projectSchema = z.object({ id: z.string(), name: z.string() });

export const projectEndpoints = defineEndpoints({
  projects: {
    list: {
      path: "/projects",
      method: "GET",
      query: z.object({ orgId: z.string() }),
      response: z.array(projectSchema),
      tags: ["projects"],
      staleTime: 30_000,
    },
    get: {
      path: "/projects/:id",
      method: "GET",
      params: z.object({ id: z.string() }),
      response: projectSchema,
      tags: ["projects"],
    },
    create: {
      path: "/projects",
      method: "POST",
      body: z.object({ name: z.string() }),
      response: projectSchema,
      invalidates: ["projects"],
    },
    remove: {
      path: "/projects/:id",
      method: "DELETE",
      params: z.object({ id: z.string() }),
      invalidates: ["projects"],
    },
  },
});

2. Create the client

React

import { createReactClient } from "@nexfetch/rest/react";

export const client = createReactClient({
  baseURL: "https://api.example.com",
  credentials: "include",
  endpoints: projectEndpoints,
  cache: { staleTime: 30_000, refetchOnFocus: true },
  retry: { retries: 3 },
});

Vanilla

import { createClient } from "@nexfetch/rest";

export const client = createClient({
  baseURL: "https://api.example.com",
  endpoints: projectEndpoints,
});

3. Use it

React

function ProjectList() {
  const { data, status, error } = client.useQuery(
    client.projects.list,
    { query: { orgId: "acme" } },
  );

  if (status === "pending") return <p>Loading...</p>;
  if (error) return <p>{error.message}</p>;

  return <ul>{data?.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}

function CreateProjectButton() {
  const createProject = client.useMutation(client.projects.create);

  return (
    <button
      disabled={createProject.status === "pending"}
      onClick={() => createProject.mutate({ body: { name: "New" } })}
    >
      Create
    </button>
  );
}

Vanilla

// Direct HTTP call (no cache):
const projects = await client.projects.list.call({ query: { orgId: "acme" } });

// Reactive cached query:
const q = client.query(client.projects.list, { query: { orgId: "acme" } });
const unsubscribe = q.state.subscribe((s) => console.log(s.data));
await q.refetch();
q.dispose();

// Mutation:
const m = client.mutation(client.projects.create);
await m.mutate({ body: { name: "Portal" } });

// Tag invalidation:
client.cache.invalidateByTag("projects");

Core concepts

Endpoints are callable nodes

Each endpoint becomes an EndpointNode with exactly one method: .call(input). Endpoint names can be anything — including fetch, query, useQuery — without colliding with framework methods.

// Literally naming an endpoint "fetch" is fine:
endpoints: {
  users: {
    fetch: { path: "/users/fetch", method: "POST", body: QuerySchema },
  },
}

// No collision:
await client.users.fetch.call({ body: { q: "ada" } });

Two ways to call an endpoint — and they mean different things

| Call | Cache? | Reactive? | Returns | |---|---|---|---| | client.<ns>.<name>.call(input) | No | No | Promise<Output> | | client.query(endpoint, input) | Yes | Yes (Observable<QueryState>) | Query<I,O> |

There is no hidden third way. Direct calls never touch the cache. Cached calls always go through client.query(...) or one of the hooks.

Operations live on the root client

query, mutation, infiniteQuery, and cache are methods of the root client, not per-endpoint properties. This is why endpoint names can be anything — they never compete with framework names.

client.query(endpoint, input);
client.mutation(endpoint);
client.infiniteQuery(endpoint, { pagination });
client.cache.invalidateByTag("tag");

Per-subscription select

select is a projection applied at the subscription level, not baked into the cache entry. Two components can observe the same cache key with different projections, each receiving its own derived state.

// Component A sees the user's name
client.useQuery(client.users.get, input, { select: (u) => u.name });
// Component B sees the full object — same cache entry, different view
client.useQuery(client.users.get, input);

Schema validation runs before the request

If an endpoint declares a body, query, or params schema, the input is validated before the HTTP request is sent. A ValidationError is thrown and the network call never happens. response schemas are still applied to the returned JSON as before.

Missing path params throw a clear error

If the path is /users/:id and you don't pass params, you get:

Missing path params: :id in "/users/:id". Pass them as { params: { ... } }

before the request is built.

API reference

Endpoint definition

interface EndpointDef {
  path: string;                // e.g. "/users/:id"
  method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
  body?: Schema;               // input schema — also drives TS types
  query?: Schema;
  params?: Schema;
  response?: Schema;           // parses the response JSON
  headers?: Record<string, string>;
  staleTime?: number;
  tags?: string[];             // for invalidateByTag
  invalidates?: string[];      // tags that this mutation invalidates
  retry?: Partial<RetryConfig>;// per-endpoint retry override
  transform?: (raw: unknown) => unknown;
}

Input shape

A single canonical input shape, inferred from the schemas:

client.projects.list.call({ query: { orgId: "acme" } });
client.projects.get.call({ params: { id: "p_123" } });
client.projects.create.call({ body: { name: "Portal" } });

If an endpoint has no body/query/params schema, its input type is void.

Client options

interface ClientOptions<T> {
  baseURL: string;
  endpoints: T;
  credentials?: RequestCredentials;
  headers?: Record<string, string>;
  cache?: {
    staleTime?: number;       // default 0
    gcTime?: number;          // default 5 * 60 * 1000
    refetchOnFocus?: boolean; // default false
  };
  retry?: {
    retries?: number;         // default 2
    retryDelay?: number;      // default 1000
    retryOn?: number[];       // default [408, 500, 502, 503, 504]
  };
  onError?: (error: ApiError) => void;
}

Vanilla client surface

interface VanillaClient<T> extends EndpointTree<T> {
  readonly cache: QueryCache;

  query<I, O>(endpoint: EndpointNode<I, O>, input: I): Query<I, O>;
  mutation<I, O>(endpoint: EndpointNode<I, O>, opts?: MutationOptions<I, O>): Mutation<I, O>;
  infiniteQuery<I, O>(endpoint: EndpointNode<I, O>, opts: InfiniteQueryOptions<I>): InfiniteQuery<I, O>;
}

React client surface

A ReactClient<T> is a VanillaClient<T> with hooks attached:

interface ReactClientExtensions {
  useQuery<I, O, Sel = O>(
    endpoint: EndpointNode<I, O>,
    input: I,
    opts?: { enabled?: boolean; refetchInterval?: number; select?: (data: O) => Sel },
  ): QueryState<Sel>;

  useMutation<I, O>(endpoint: EndpointNode<I, O>): UseMutationResult<I, O>;

  useInfiniteQuery<I, O>(
    endpoint: EndpointNode<I, O>,
    opts: InfiniteQueryOptions<I>,
  ): UseInfiniteQueryResult<O>;
}

Calling .useQuery on a vanilla client is a TypeScript error — not a runtime throw.

Query handle

class Query<I, O> {
  readonly state: Observable<QueryState<O>>;
  project<Sel>(select: (data: O) => Sel): Observable<QueryState<Sel>>;
  refetch(): Promise<void>;
  invalidate(): void;
  dispose(): void;
}

interface QueryState<T> {
  data: T | undefined;
  error: Error | undefined;
  status: "idle" | "pending" | "success" | "error";
  isFetching: boolean;
}

Mutation handle

class Mutation<I, O> {
  readonly state: Observable<MutationState<O>>;
  mutate(input: I, opts?: MutationOptions<I, O>): Promise<O>;
  reset(): void;
}

interface MutationState<T> {
  data: T | undefined;
  error: Error | undefined;
  status: "idle" | "pending" | "success" | "error";
}

Infinite query handle

Works from any framework (vanilla, React, Vue). Each page is stored in the shared cache with the endpoint's tags, so invalidateByTag reaches paginated data.

interface InfiniteQueryOptions<I> {
  input?: I;
  pagination:
    | { type: "offset"; pageSize: number; pageParam?: string }
    | { type: "cursor"; cursorField: string; cursorParam?: string }
    | { type: "total"; totalField: string; pageSize: number; pageParam?: string };
  enabled?: boolean;
}

class InfiniteQuery<I, O> {
  readonly state: Observable<InfiniteQueryState<O>>;
  fetchNext(): Promise<void>;
  refetch(): Promise<void>;
  dispose(): void;
}

interface InfiniteQueryState<T> {
  pages: T[][];
  flat: T[];
  status: "idle" | "pending" | "success" | "error";
  error: Error | undefined;
  isFetchingMore: boolean;
  hasMore: boolean;
}

Observables — the reactive contract

@nexfetch/rest exposes a minimal reactive contract called Observable<T>:

interface Observable<T> {
  get(): T;
  subscribe(listener: (value: T) => void): () => void;
}

Query.state, Mutation.state, and InfiniteQuery.state are all Observable<...>. The React adapter bridges them via useSyncExternalStore. Writing an adapter for Vue, Solid, Svelte, or Preact Signals requires only a small bridge to their respective reactive primitive.

A 40-line in-house implementation powers this — no external reactive library is required.

Cache

class QueryCache {
  getOrCreate<T>(key: string, fetcher, meta): CacheEntry<T>;
  get<T>(key: string): CacheEntry<T> | undefined;
  subscribe(key: string): () => void;
  invalidateByTag(tag: string): void;
  invalidateByKey(key: string): void;
  ensureFresh(key: string): void;
}

Features:

  • Stable query keys derived from endpoint path + input (order-independent, nested objects supported)
  • Tag index for O(1) invalidation by tag
  • Reference-counted subscriptions with gcTime-delayed cleanup
  • Optional refetch-on-visibilitychange
  • Same entries are shared between client.query(...) calls and hooks

Retry

Exponential backoff on configured HTTP status codes. Configurable globally or per endpoint:

endpoints: {
  flakyApi: {
    get: { path: "/thing", method: "GET", retry: { retries: 5 } },
  }
}

Deduplication

Concurrent GET requests with the same method+path+input share a single in-flight promise. Non-GET methods are never deduplicated.

Validation

| Schema field | When it runs | What happens on failure | |---|---|---| | params | Before building the URL | ValidationError thrown; request never sent | | query | Before building the URL | ValidationError thrown; request never sent | | body | Before the fetch call | ValidationError thrown; request never sent | | response | After JSON is parsed | ValidationError thrown; caller sees the rejection |

Any object with a .parse() method works (Zod, Valibot, Arktype, custom).

React hooks

import { useQuery, useMutation, useInfiniteQuery, useObservable } from "@nexfetch/rest/react";

The hooks are also available bound to a specific client as client.useQuery / client.useMutation / client.useInfiniteQuery via createReactClient.

useObservable(obs) is exported so you can subscribe to arbitrary Observable<T> values from components (e.g., Query.project(...) results).

Vue

import { createClient } from "@nexfetch/rest/vue";

Today this re-exports the vanilla client. Native Vue composables (backed by shallowRef) are a roadmap item. In the meantime you can write a small bridge:

import { shallowRef } from "vue";
import type { Observable } from "@nexfetch/rest";

export function useObservable<T>(obs: Observable<T>) {
  const state = shallowRef(obs.get());
  obs.subscribe((v) => (state.value = v));
  return state;
}

Migration from 0.x

| 0.x | 1.0 | |---|---| | createApiClient (from /react) | createReactClient (from /react) | | createApiClient (from /vanilla) | createClient (from root) | | api.projects.useQuery("list", input) | client.useQuery(client.projects.list, input) | | api.projects.useMutation("create") | client.useMutation(client.projects.create) | | api.projects.useInfiniteQuery("list", opts) | client.useInfiniteQuery(client.projects.list, opts) | | api.projects.query("list", input) | client.query(client.projects.list, input) | | api.projects.mutation("create") | client.mutation(client.projects.create) | | api.projects.fetch("list", input) | client.projects.list.call(input) | | api.projects.list(input) | client.projects.list.call(input) | | api.projects.invalidateByTag("tag") | client.cache.invalidateByTag("tag") | | entry.$state.subscribe(fn) | query.state.subscribe(fn) | | { isPending, error } | { status, error }status is "idle" \| "pending" \| "success" \| "error" |

When to use this library

| Tool | Use when | Avoid when | |---|---|---| | fetch | Minimal needs, full control | You don't want to reimplement cache/dedupe/invalidation | | ky | You want a small HTTP wrapper | You need cached reactive queries and tag invalidation | | @nexfetch/rest | You want typed endpoints, explicit cache semantics, zero-dep reactivity, per-subscription select | You need mature devtools, SSR hydration, or TanStack's ecosystem | | TanStack Query | Broadest feature set, biggest ecosystem | You want a smaller, opinionated layer |

License

MIT