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 🙏

© 2025 – Pkg Stats / Ryan Hefner

enlace

v0.0.1-beta.4

Published

Type-safe API client with React hooks and Next.js integration.

Readme

enlace

Type-safe API client with React hooks and Next.js integration.

Installation

npm install enlace

Quick Start

import { createEnlaceHook, Endpoint } from "enlace";

type ApiSchema = {
  posts: {
    $get: Endpoint<Post[], ApiError>;
    $post: Endpoint<Post, ApiError, CreatePost>;
    _: {
      $get: Endpoint<Post, ApiError>;
      $delete: Endpoint<void, ApiError>;
    };
  };
};

const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");

Schema Conventions

Defining a schema is recommended for full type safety, but optional. You can go without types:

// Without schema (untyped, but still works!)
const useAPI = createEnlaceHook("https://api.example.com");
const { data } = useAPI((api) => api.any.path.you.want.get());
// With schema (recommended for type safety)
const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");

Schema Structure

  • $get, $post, $put, $patch, $delete — HTTP method endpoints
  • _ — Dynamic path segment (e.g., /users/:id)
import { Endpoint } from "enlace";

type ApiSchema = {
  users: {
    $get: Endpoint<User[], ApiError>;           // GET /users
    $post: Endpoint<User, ApiError>;            // POST /users
    _: {                                        // /users/:id
      $get: Endpoint<User, ApiError>;           // GET /users/:id
      $put: Endpoint<User, ApiError>;           // PUT /users/:id
      $delete: Endpoint<void, ApiError>;        // DELETE /users/:id
      profile: {
        $get: Endpoint<Profile, ApiError>;      // GET /users/:id/profile
      };
    };
  };
};

// Usage
api.users.get();              // GET /users
api.users[123].get();         // GET /users/123
api.users[123].profile.get(); // GET /users/123/profile

Endpoint Type

type Endpoint<TData, TError, TBody = never> = {
  data: TData;    // Response data type
  error: TError;  // Error response type (required)
  body: TBody;    // Request body type (optional)
};

// Examples
type GetUsers = Endpoint<User[], ApiError>;                   // GET, no body
type CreateUser = Endpoint<User, ApiError, CreateUserInput>;  // POST with body
type DeleteUser = Endpoint<void, NotFoundError>;              // DELETE, no response data

React Hooks

Query Mode (Auto-Fetch)

For GET requests that fetch data automatically:

function Posts({ page, limit }: { page: number; limit: number }) {
  const { data, loading, error, ok } = useAPI((api) =>
    api.posts.get({ query: { page, limit, published: true } })
  );

  if (loading) return <div>Loading...</div>;
  if (!ok) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Features:

  • Auto-fetches on mount
  • Re-fetches when dependencies change (no deps array needed!)
  • Returns cached data while revalidating
  • Request deduplication — identical requests from multiple components trigger only one fetch

Conditional Fetching

Skip fetching with the enabled option:

function ProductForm({ id }: { id: string | "new" }) {
  // Skip fetching when creating a new product
  const { data, loading } = useAPI(
    (api) => api.products[id].get(),
    { enabled: id !== "new" }
  );

  if (id === "new") return <CreateProductForm />;
  if (loading) return <div>Loading...</div>;
  return <EditProductForm product={data} />;
}
// Also useful when waiting for a dependency
function UserPosts({ userId }: { userId: string | undefined }) {
  const { data } = useAPI(
    (api) => api.users[userId!].posts.get(),
    { enabled: userId !== undefined }
  );
}
function Post({ id }: { id: number }) {
  // Automatically re-fetches when `id` or query values change
  const { data } = useAPI((api) => api.posts[id].get({ query: { include: "author" } }));
  return <div>{data?.title}</div>;
}

Request Deduplication

Multiple components requesting the same data will share a single network request:

// Both components render at the same time
function PostTitle({ id }: { id: number }) {
  const { data } = useAPI((api) => api.posts[id].get());
  return <h1>{data?.title}</h1>;
}

function PostBody({ id }: { id: number }) {
  const { data } = useAPI((api) => api.posts[id].get());
  return <p>{data?.body}</p>;
}

// Only ONE fetch request is made to GET /posts/123
// Both components share the same cached result
function PostPage() {
  return (
    <>
      <PostTitle id={123} />
      <PostBody id={123} />
    </>
  );
}

Selector Mode (Manual Trigger)

For mutations or lazy-loaded requests:

function DeleteButton({ id }: { id: number }) {
  const { trigger, loading } = useAPI((api) => api.posts[id].delete);

  return (
    <button onClick={() => trigger()} disabled={loading}>
      {loading ? "Deleting..." : "Delete"}
    </button>
  );
}

With request body:

function CreatePost() {
  const { trigger, loading, data } = useAPI((api) => api.posts.post);

  const handleSubmit = async (title: string) => {
    const result = await trigger({ body: { title } });
    if (result.ok) {
      console.log("Created:", result.data);
    }
  };

  return <button onClick={() => handleSubmit("New Post")}>Create</button>;
}

Dynamic Path Parameters

Use :paramName syntax for dynamic IDs passed at trigger time:

function PostList({ posts }: { posts: Post[] }) {
  // Define once with :id placeholder
  const { trigger, loading } = useAPI((api) => api.posts[":id"].delete);

  const handleDelete = (postId: number) => {
    // Pass the actual ID when triggering
    trigger({ pathParams: { id: postId } });
  };

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {post.title}
          <button onClick={() => handleDelete(post.id)} disabled={loading}>
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Multiple path parameters:

const { trigger } = useAPI((api) => api.users[":userId"].posts[":postId"].delete);

trigger({ pathParams: { userId: "1", postId: "42" } });
// → DELETE /users/1/posts/42

With request body:

const { trigger } = useAPI((api) => api.products[":id"].patch);

trigger({
  pathParams: { id: "123" },
  body: { name: "Updated Product" },
});
// → PATCH /products/123 with body

Caching & Auto-Revalidation

Automatic Cache Tags (Zero Config)

Tags are automatically generated from URL paths — no manual configuration needed:

// GET /posts       → tags: ['posts']
// GET /posts/123   → tags: ['posts', 'posts/123']
// GET /users/5/posts → tags: ['users', 'users/5', 'users/5/posts']

Mutations automatically revalidate matching tags:

const { trigger } = useAPI((api) => api.posts.post);

// POST /posts automatically revalidates 'posts' tag
// All queries with 'posts' tag will refetch!
trigger({ body: { title: "New Post" } });

This means in most cases, you don't need to specify any tags manually. The cache just works.

How It Works

  1. Queries automatically cache with tags derived from the URL
  2. Mutations automatically revalidate tags derived from the URL
  3. All queries matching those tags refetch automatically
// Component A: fetches posts (cached with tag 'posts')
const { data } = useAPI((api) => api.posts.get());

// Component B: creates a post
const { trigger } = useAPI((api) => api.posts.post);
trigger({ body: { title: "New" } });
// → Automatically revalidates 'posts' tag
// → Component A refetches automatically!

Stale Time

Control how long cached data is considered fresh:

const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com", {}, {
  staleTime: 5000, // 5 seconds
});
  • staleTime: 0 (default) — Always revalidate on mount
  • staleTime: 5000 — Data is fresh for 5 seconds
  • staleTime: Infinity — Never revalidate automatically

Manual Tag Override (Optional)

Override auto-generated tags when needed:

// Custom cache tags
const { data } = useAPI((api) => api.posts.get({ tags: ["my-custom-tag"] }));

// Custom revalidation tags
trigger({
  body: { title: "New" },
  revalidateTags: ["posts", "dashboard"], // Override auto-generated
});

Disable Auto-Revalidation

const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com", {}, {
  autoGenerateTags: false,     // Disable auto tag generation
  autoRevalidateTags: false,   // Disable auto revalidation
});

Hook Options

const useAPI = createEnlaceHook<ApiSchema>(
  "https://api.example.com",
  {
    // Default fetch options
    headers: { Authorization: "Bearer token" },
  },
  {
    // Hook options
    autoGenerateTags: true,    // Auto-generate cache tags from URL
    autoRevalidateTags: true,  // Auto-revalidate after mutations
    staleTime: 0,              // Cache freshness duration (ms)
  }
);

Return Types

Query Mode

// Basic usage
const result = useAPI((api) => api.posts.get());

// With options
const result = useAPI(
  (api) => api.posts.get(),
  { enabled: true }  // Skip fetching when false
);

type UseEnlaceQueryResult<TData, TError> = {
  loading: boolean;   // No cached data and fetching
  fetching: boolean;  // Request in progress
  ok: boolean | undefined;
  data: TData | undefined;
  error: TError | undefined;
};

Selector Mode

type UseEnlaceSelectorResult<TMethod> = {
  trigger: TMethod;   // Function to trigger the request
  loading: boolean;
  fetching: boolean;
  ok: boolean | undefined;
  data: TData | undefined;
  error: TError | undefined;
};

Request Options

type RequestOptions = {
  query?: Record<string, unknown>;        // Query parameters
  body?: TBody;                           // Request body
  tags?: string[];                        // Cache tags (GET only)
  revalidateTags?: string[];              // Tags to invalidate after mutation
  pathParams?: Record<string, string | number>;  // Dynamic path parameters
};

Next.js Integration

Server Components

Use createEnlace from enlace/next for server components:

import { createEnlace } from "enlace/next";

const api = createEnlace<ApiSchema>("https://api.example.com", {}, {
  autoGenerateTags: true,
});

export default async function Page() {
  const { data } = await api.posts.get({
    revalidate: 60, // ISR: revalidate every 60 seconds
  });

  return <PostList posts={data} />;
}

Client Components

Use createEnlaceHook from enlace/next/hook for client components:

"use client";

import { createEnlaceHook } from "enlace/next/hook";

const useAPI = createEnlaceHook<ApiSchema>("https://api.example.com");

Server-Side Revalidation

Trigger Next.js cache revalidation after mutations:

// actions.ts
"use server";

import { revalidateTag, revalidatePath } from "next/cache";

export async function revalidateAction(tags: string[], paths: string[]) {
  for (const tag of tags) {
    revalidateTag(tag);
  }
  for (const path of paths) {
    revalidatePath(path);
  }
}
// useAPI.ts
import { createEnlaceHook } from "enlace/next/hook";
import { revalidateAction } from "./actions";

const useAPI = createEnlaceHook<ApiSchema>("/api", {}, {
  revalidator: revalidateAction,
});

In components:

function CreatePost() {
  const { trigger } = useAPI((api) => api.posts.post);

  const handleCreate = () => {
    trigger({
      body: { title: "New Post" },
      revalidateTags: ["posts"],      // Passed to revalidator
      revalidatePaths: ["/posts"],    // Passed to revalidator
    });
  };
}

Next.js Request Options

api.posts.get({
  tags: ["posts"],           // Next.js cache tags
  revalidate: 60,            // ISR revalidation (seconds)
  revalidateTags: ["posts"], // Tags to invalidate after mutation
  revalidatePaths: ["/"],    // Paths to revalidate after mutation
  skipRevalidator: false,    // Skip server-side revalidation
});

Relative URLs

Works with Next.js API routes:

// Client component calling /api/posts
const useAPI = createEnlaceHook<ApiSchema>("/api");

API Reference

createEnlaceHook<TSchema>(baseUrl, options?, hookOptions?)

Creates a React hook for making API calls.

Parameters:

  • baseUrl — Base URL for requests
  • options — Default fetch options (headers, cache, etc.)
  • hookOptions — Hook configuration

Hook Options:

type EnlaceHookOptions = {
  autoGenerateTags?: boolean;    // default: true
  autoRevalidateTags?: boolean;  // default: true
  staleTime?: number;            // default: 0
};

Re-exports from enlace-core

  • Endpoint — Type helper for schema definition
  • EnlaceResponse — Response type
  • EnlaceOptions — Fetch options type

License

MIT