JSPM

@classytic/arc-next

0.3.1
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 43
  • Score
    100M100P100Q82075F
  • License MIT

React + TanStack Query SDK for Arc resources

Package Exports

  • @classytic/arc-next
  • @classytic/arc-next/api
  • @classytic/arc-next/client
  • @classytic/arc-next/hooks
  • @classytic/arc-next/mutation
  • @classytic/arc-next/prefetch
  • @classytic/arc-next/query
  • @classytic/arc-next/query-client

Readme

@classytic/arc-next

React + TanStack Query SDK for Arc resources. Typed CRUD hooks with optimistic updates, automatic rollback, multi-tenant scoping, pagination normalization, and detail cache prefilling. No separate state management library needed.

Requires: React 19+, TanStack React Query 5+

Install

npm install @classytic/arc-next

Peer dependencies:

npm install react@^19 @tanstack/react-query@^5

Setup

Call the configuration functions once at app init (e.g., in your root providers):

import { configureClient, configureAuth } from "@classytic/arc-next/client";
import { configureToast } from "@classytic/arc-next/mutation";
import { configureNavigation } from "@classytic/arc-next/hooks";
import { toast } from "sonner";
import { useRouter } from "next/navigation";

// Required — sets the API base URL and auth mode
configureClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL!,
  authMode: "cookie",   // 'cookie' for Better Auth, 'bearer' for token auth (default)
  // credentials: 'omit', // override if you don't want cookies sent cross-origin
});

// Optional — auto-inject tenant context into queries/mutations
configureAuth({
  getOrgId: () => activeTenantId, // return current tenant/org/workspace ID
  getToken: () => null, // null for cookie auth (token only for bearer)
});

// Optional — pluggable toast (defaults to console)
configureToast({ success: toast.success, error: toast.error });

// Optional — enables useNavigation() routing (defaults to cache-only)
configureNavigation(useRouter);

Subpath Exports

Import Purpose "use client"
@classytic/arc-next Root — same as /hooks (createCrudHooks, configureNavigation) Yes
@classytic/arc-next/client configureClient, configureAuth, createClient, handleApiRequest, createQueryString, ArcApiError, isArcApiError, getAuthMode, getAuthContext No
@classytic/arc-next/api BaseApi, createCrudApi, response types, type guards No
@classytic/arc-next/query createQueryKeys, createCacheUtils, useListQuery, useDetailQuery Yes
@classytic/arc-next/mutation configureToast, useMutationWithTransition, useOptimisticMutation Yes
@classytic/arc-next/hooks createCrudHooks, configureNavigation Yes
@classytic/arc-next/query-client getQueryClient (SSR-safe singleton) No
@classytic/arc-next/prefetch createCrudPrefetcher, dehydrate (SSR prefetch) No

No barrel index — every file is its own entry point. Tree-shakeable (sideEffects: false).

Quick Start

1. Define API

import { createCrudApi } from "@classytic/arc-next/api";

interface Product {
  _id: string;
  name: string;
  price: number;
  organizationId: string;
}

interface CreateProduct {
  name: string;
  price: number;
}

export const productsApi = createCrudApi<Product, CreateProduct>(
  "products",
  { basePath: "/api" }
);

2. Create hooks

import { createCrudHooks } from "@classytic/arc-next/hooks";
import { productsApi } from "./products-api";

export const {
  KEYS: productKeys,
  cache: productCache,
  useList: useProducts,
  useDetail: useProduct,
  useActions: useProductActions,
  useNavigation: useProductNavigation,
} = createCrudHooks<Product, CreateProduct>({
  api: productsApi,
  entityKey: "products",
  singular: "Product",
});

3. Use in components

"use client";

export function ProductsPage() {
  const { items, pagination, isLoading } = useProducts(null, {
    organizationId: "org-123",
  }, { public: true });

  const { create, remove, isCreating } = useProductActions();

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <button
        onClick={() => create({ data: { name: "Widget", price: 9.99 } })}
        disabled={isCreating}
      >
        Add Product
      </button>
      {items.map((p) => (
        <div key={p._id}>
          {p.name} — ${p.price}
          <button onClick={() => remove({ id: p._id })}>Delete</button>
        </div>
      ))}
      {pagination && <span>{pagination.total} total</span>}
    </div>
  );
}

API Reference

configureClient(config)

configureClient({
  baseUrl: string;                          // Required — API base URL
  authMode?: 'cookie' | 'bearer';          // Default: 'bearer'
  credentials?: RequestCredentials;         // Default: derived from authMode
  internalApiKey?: string;                  // Optional — sent as x-internal-api-key header
  defaultHeaders?: Record<string, string>; // Optional — merged into every request
});
  • authMode: 'bearer' (default) — requires a token; queries disabled until token provided; credentials: 'same-origin'
  • authMode: 'cookie' — HTTP-only cookies (e.g. Better Auth); queries always enabled; credentials: 'include'
  • credentials — explicit override: 'include' (send cookies cross-origin), 'same-origin' (same-origin only), 'omit' (never send cookies)

Must be called before any API requests. Throws if not configured.

configureAuth(config)

configureAuth({
  getToken?: () => string | null;   // For bearer auth — return access token
  getOrgId?: () => string | null;   // Return active organization ID
});

Auto-injects token and tenant ID (sent as x-organization-id header) into queries/mutations. The header name is a convention — your backend controls how it's read and which field it maps to (organizationId, workspaceId, teamId, etc.). Hooks use the new signature (no explicit token param) — legacy signature still works.

handleApiRequest<T>(method, endpoint, options?)

Universal fetch wrapper. Handles JSON, PDF, image, CSV, and text responses.

const result = await handleApiRequest<ApiResponse<User>>("GET", "/api/users/me");
const list = await handleApiRequest<PaginatedResponse<Product>>("GET", "/api/products?page=1");

Options:

  • body — request body (auto-serializes JSON, passes FormData as-is)
  • token — Bearer token
  • organizationId — sent as x-organization-id header
  • headerOptions — additional headers merged into request
  • signal — AbortSignal for request cancellation
  • revalidate / tags / cache — Next.js fetch extensions

createQueryString(params)

MongoKit-compatible query string builder:

  • Arrays → field[in]=a,b,c
  • populateOptionspopulate[path][select]=field1,field2
  • nullfield=null

createCrudApi<TDoc, TCreate, TUpdate>(entity, config?)

Creates a typed API client instance.

const api = createCrudApi<Product, CreateProduct>("products", {
  basePath: "/api",       // default: "/api/v1"
  defaultParams: { limit: 20 },
  cache: "no-store",      // default
  headers: {              // optional — sent with every request from this instance
    "x-arc-scope": "platform",  // e.g. for superadmin elevation
  },
});

Methods:

Method Signature
getAll ({ token?, organizationId?, params? }) → PaginatedResponse<T>
getById ({ id, token?, organizationId?, params? }) → ApiResponse<T>
create ({ data, token?, organizationId? }) → ApiResponse<T>
update ({ id, data, token?, organizationId? }) → ApiResponse<T>
delete ({ id, token?, organizationId? }) → DeleteResponse
upload ({ data: FormData, id?, path?, token?, organizationId? }) → ApiResponse<T>
search ({ searchParams?, params?, token?, organizationId? }) → PaginatedResponse<T>
findBy ({ field, value, operator?, token?, organizationId? }) → PaginatedResponse<T>
request (method, endpoint, { data?, params?, token? }) → T

prepareParams(params) — processes query params: critical filters (organizationId, ownerId) preserved as null, arrays → field[in], pagination parsed to int.

createCrudHooks<T, TCreate, TUpdate>(config)

Factory that returns everything you need. The api parameter accepts any createCrudApi() result directly — no casts needed. Types are derived from BaseApi via Pick, so generics thread through automatically:

const {
  KEYS, cache,
  useList, useDetail, useInfiniteList,
  useActions, useUpload, useSearch, useCustomMutation,
  useNavigation,
} = createCrudHooks<Product, CreateProduct>({
    api: productsApi,       // from createCrudApi() — types inferred, no cast
    entityKey: "products",  // TanStack Query key prefix
    singular: "Product",    // for toast messages
    defaults: {             // optional
      staleTime: 60_000,
      messages: { createSuccess: "Product added!" },
    },
    callbacks: {            // optional
      onCreate: {
        onSuccess: (data) => console.log("Created:", data),
        onSettled: (data, error) => console.log("Done"),
      },
    },
  });

Returned hooks:

useList(token, params?, options?)

const { items, pagination, isLoading, isFetching, refetch } = useList(
  token,
  { organizationId: "org-123", status: "active" },
  { public: true, staleTime: 30_000, prefillDetailCache: true }
);
  • Auto-scopes list query keys by tenant context (when present → tenant scope, otherwise → super-admin)
  • Normalizes pagination from docs/data/items/results formats
  • Prefills detail cache from list results (skips re-fetch on navigate)
  • options.public: true — enables query without token

select transform — transform raw API data before it reaches your component:

const { items } = useList(token, { organizationId }, {
  select: (data) => ({
    ...data,
    docs: data.docs.map((p) => ({ ...p, displayName: `${p.name} ($${p.price})` })),
  }),
});

useDetail(id, token, options?)

const { item, isLoading } = useDetail(productId, token, {
  organizationId: "org-123",
});
  • Disabled when id is null (conditional fetching)
  • Extracts item from { data: T } wrapper

select transform:

const { item } = useDetail(productId, token, {
  select: (data) => ({ ...data.data, fullName: `${data.data.firstName} ${data.data.lastName}` }),
});

useActions()

const { create, update, remove, isCreating, isUpdating, isDeleting, isMutating } =
  useActions();

// All mutations have optimistic updates + automatic rollback on error
await create({ data: { name: "New" }, organizationId: "org-123" });
await update({ id: "123", data: { name: "Updated" } });
await remove({ id: "123" });

// Per-call callbacks
await create(
  { data: { name: "New" } },
  { onSuccess: (item) => navigate(`/products/${item._id}`) }
);
  • Create — optimistic: prepends to list with temp ID
  • Update — optimistic: patches item in list + detail cache
  • Delete — optimistic: removes from list + detail cache
  • All roll back automatically on error

useNavigation()

const navigate = useNavigation();
navigate(`/products/${id}`, product);              // push + cache prefill
navigate(`/products/${id}`, product, { replace: true }); // replace

Sets detail cache before navigation (instant page load, no loading spinner). Requires configureNavigation(useRouter) — without it, only sets cache (no routing).

useInfiniteList(token, params?, options?)

Cursor-based infinite scrolling with automatic page aggregation:

const { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading } =
  useInfiniteList(token, { organizationId: "org-123", limit: 20 });
  • Supports both keyset (hasMore/next) and offset (hasNext/page) pagination
  • Returns flattened items across all pages
  • Auto-scopes query keys like useList

useUpload(options?)

Upload FormData with cache invalidation:

const { mutateAsync: upload, isPending } = useUpload({
  messages: { success: "Uploaded!", error: "Upload failed" },
  onSuccess: (data) => console.log("Uploaded:", data),
});

// Post to base collection URL
await upload({ data: formData });
// Post to /products/{id}/upload
await upload({ data: formData, id: "doc-123" });
// Post to /products/bulk-import (custom path takes precedence over id)
await upload({ data: formData, path: "bulk-import" });

Requires api.upload to be defined. Throws if not available.

useSearch(query, params?, options?)

Search with automatic query key scoping:

const { items, pagination, isLoading } = useSearch("widget", {
  organizationId: "org-123",
});
  • Disabled when query is empty
  • Requires api.search to be defined

useCustomMutation<TData, TVariables>(config)

Build custom mutations that share the entity's toast and invalidation patterns:

const { mutateAsync: publish, isPending } = useCustomMutation({
  mutationFn: (id: string) => api.request("POST", `${api.baseUrl}/${id}/publish`),
  invalidateQueries: [productKeys.lists()],
  messages: { success: "Published!", error: "Failed to publish" },
});

Query Keys (KEYS)

KEYS.all                          // ["products"]
KEYS.lists()                      // ["products", "list"]
KEYS.list(params)                 // ["products", "list", params]
KEYS.details()                    // ["products", "detail"]
KEYS.detail(id)                   // ["products", "detail", id]
KEYS.custom("stats", orgId)       // ["products", "stats", orgId]
KEYS.scopedList("tenant", params) // ["products", "list", { _scope: "tenant", ...params }]

Cache Utilities (cache)

await cache.invalidateAll(queryClient);
await cache.invalidateLists(queryClient);
await cache.invalidateDetail(queryClient, id);
cache.setDetail(queryClient, id, data);
cache.getDetail(queryClient, id);       // T | undefined
cache.removeDetail(queryClient, id);

getQueryClient(overrides?)

SSR-safe singleton. Server: new per request. Browser: reuses singleton.

import { getQueryClient } from "@classytic/arc-next/query-client";
import { QueryClientProvider } from "@tanstack/react-query";

function Providers({ children }) {
  const queryClient = getQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

Defaults: staleTime: 5min, gcTime: 30min, retry: 0, refetchOnWindowFocus: false.

SSR Prefetch (Server Components)

Pre-populate the query cache on the server to avoid loading spinners:

// products-prefetch.ts
import { createCrudPrefetcher } from "@classytic/arc-next/prefetch";
import { productsApi } from "@/api/products-api";

export const productsPrefetcher = createCrudPrefetcher(productsApi, "products");
// app/products/page.tsx (server component)
import { getQueryClient } from "@classytic/arc-next/query-client";
import { dehydrate } from "@classytic/arc-next/prefetch";
import { HydrationBoundary } from "@tanstack/react-query";
import { productsPrefetcher } from "@/prefetch/products-prefetch";

export default async function ProductsPage() {
  const queryClient = getQueryClient();
  await productsPrefetcher.prefetchList(queryClient, { limit: 20 });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProductsList />
    </HydrationBoundary>
  );
}

Methods: prefetchList(queryClient, params?, options?), prefetchDetail(queryClient, id, options?)

Custom Mutations

For operations beyond CRUD (publish, schedule, upload):

useMutationWithTransition(config)

Mutation + React 19 useTransition for smooth cache invalidation:

import { useMutationWithTransition } from "@classytic/arc-next/mutation";

export function usePublishPost() {
  return useMutationWithTransition({
    mutationFn: (id: string) =>
      postsApi.request("POST", `${postsApi.baseUrl}/${id}/publish`),
    invalidateQueries: [postKeys.all],
    messages: { success: "Published!", error: "Failed to publish" },
    useTransition: true, // default
    showToast: true, // default
  });
}

Returns: { mutate, mutateAsync, isPending, isSuccess, isError, error, data, reset }

useMutationWithOptimistic(config)

Mutation + optimistic updates + automatic rollback:

import { useMutationWithOptimistic } from "@classytic/arc-next/mutation";

export function useToggleFavorite() {
  return useMutationWithOptimistic({
    mutationFn: ({ id, isFav }) =>
      api.request("PATCH", `/api/products/${id}`, {
        data: { favorite: !isFav },
      }),
    queryKeys: [productKeys.lists()],
    optimisticUpdate: (old, { id, isFav }) =>
      updateListCache(old, (items) =>
        items.map((i) => (getItemId(i) === id ? { ...i, favorite: !isFav } : i))
      ),
    messages: { success: "Updated!" },
  });
}

Query Config Presets

import { QUERY_CONFIGS } from "@classytic/arc-next/mutation";

// Use in useList options:
useProducts(token, {}, { ...QUERY_CONFIGS.realtime });
Preset staleTime refetchInterval
realtime 20s 30s
frequent 1min
stable 5min
static 10min

Low-Level Utilities

updateListCache(listData, updater)

Transforms list cache regardless of format — well-known keys (docs[], data[], items[], results[]), custom keys (products[], users[], etc.), or raw arrays. Automatically adjusts total/totalDocs counts when items are added or removed (optimistic add/delete).

import { updateListCache } from "@classytic/arc-next/query";

queryClient.setQueryData(KEYS.lists(), (old) =>
  updateListCache(old, (items) => items.filter((i) => i.status !== "archived"))
);

getItemId(item)

Extracts _id or id from any item. Returns string | null.

normalizePagination(data)

Converts any pagination response format to a normalized PaginationData object. Detects pagination method (offset, keyset, aggregate) and normalizes all fields: total/totalDocs, pages/totalPages, page/currentPage, hasNext/hasNextPage/hasMore, hasPrev/hasPrevPage, next (keyset cursor).

extractItems<T>(data)

Extracts the items array from any response format. Checks well-known keys first (docs, data, items, results), then falls back to finding the first top-level array — so { products: [...] } or { users: [...] } works without configuration.

Multi-Client (Multiple APIs)

By default, configureClient() sets a single global baseUrl. Use createClient() when your app talks to multiple backends.

Create isolated clients

import { createClient } from "@classytic/arc-next/client";
import { toast } from "sonner";
import { useRouter } from "next/navigation";

const analyticsClient = createClient({
  baseUrl: "https://analytics.example.com",
  internalApiKey: "analytics-key",
  toast: { success: toast.success, error: toast.error },
  navigation: useRouter,
});

Use with createCrudApi

Pass client in the config — requests go through the client's baseUrl instead of the global one:

const eventsApi = createCrudApi("events", {
  basePath: "/api",
  client: analyticsClient,
});

Use with createCrudHooks

Pass client — toast and navigation use the client's handlers instead of globals:

const { useList, useActions } = createCrudHooks({
  api: eventsApi,
  entityKey: "events",
  singular: "Event",
  client: analyticsClient,
});

Direct requests

const data = await analyticsClient.request("GET", "/api/stats");
const result = await analyticsClient.request("POST", "/api/events", {
  body: { type: "page_view" },
});

Response Types

import type {
  ApiResponse,                   // { success, data?, message? }
  PaginatedResponse,             // OffsetPaginationResponse | KeysetPaginationResponse | AggregatePaginationResponse
  OffsetPaginationResponse,      // { docs[], page, limit, total, pages, hasNext, hasPrev }
  KeysetPaginationResponse,      // { docs[], limit, hasMore, next }
  AggregatePaginationResponse,   // same shape as offset
  DeleteResponse,                // { success, data?: { message?, id?, soft? } }
} from "@classytic/arc-next/api";

// Type guards
import {
  isOffsetPagination,
  isKeysetPagination,
  isAggregatePagination,
} from "@classytic/arc-next/api";

Error Handling

All API errors throw ArcApiError:

import { ArcApiError, isArcApiError } from "@classytic/arc-next/client";

try {
  await productsApi.create({ data: { name: "" } });
} catch (err) {
  if (isArcApiError(err)) {
    console.log(err.status);      // HTTP status code
    console.log(err.message);     // Error message from server
    console.log(err.fieldErrors); // { field: "message" } or null
    console.log(err.endpoint);    // Request endpoint
    console.log(err.method);      // HTTP method
  }
}

Common Patterns

Multi-tenant data fetching

// Tenant ID in params → scoped query key → isolated cache per tenant
// The param name is up to you — arc-next sends it as x-organization-id header,
// your backend maps it to whatever tenant field your schema uses.
const { items } = useProducts(token, { organizationId: currentTenantId });

Public endpoints (no auth)

const { items } = useProducts(null, {}, { public: true });

Conditional fetching

const { item } = useProduct(selectedId, token); // disabled when selectedId is null

Per-call callbacks

await create(
  { data: formData, organizationId: org },
  {
    onSuccess: (product) => router.push(`/products/${product._id}`),
    onError: (err) => setFieldErrors(err),
    onSettled: (data, error) => setSubmitting(false), // fires after success or error
  }
);
const navigate = useProductNavigation();
// Prefills detail cache → no loading spinner on detail page
navigate(`/products/${product._id}`, product);

Per-instance headers

// All requests from this API include x-arc-scope header
const adminApi = createCrudApi("users", {
  headers: { "x-arc-scope": "platform" },
});

Features

  • CRUD FactorycreateCrudApi + createCrudHooks generates typed API clients and React Query hooks
  • Optimistic Updates — Create, update, delete with instant UI feedback and automatic rollback
  • Multi-Tenant Scoping — Tenant ID sent via x-organization-id header + scoped list query keys. Backend controls the tenant field name and access enforcement.
  • Pagination Normalization — Handles docs/data/items/results + any custom key, offset/keyset/aggregate pagination
  • Detail Cache Prefilling — List results auto-populate detail query cache
  • React 19 TransitionsuseMutationWithTransition wraps invalidation in startTransition
  • Cookie & Bearer AuthauthMode: 'cookie' for Better Auth, 'bearer' for token auth, configurable credentials policy
  • SSR PrefetchcreateCrudPrefetcher + dehydrate for server component data loading
  • Multi-ClientcreateClient() for multiple API backends side by side
  • Pluggable ToastconfigureToast() — use sonner, react-hot-toast, or anything
  • Pluggable NavigationconfigureNavigation() — use Next.js, React Router, or any router
  • SSR-Safe QueryClientgetQueryClient() — singleton in browser, new per request on server
  • Per-Instance Headersconfig.headers on createCrudApi merged into every request
  • select Transform — Transform raw API data before it reaches components (useList, useDetail)
  • onSettled Lifecycle — Callback that fires after both success and error, at factory and per-call level
  • Automatic Request Cancellation — AbortSignal passthrough — unmounted components cancel in-flight requests automatically
  • Query Config PresetsQUERY_CONFIGS.realtime/frequent/stable/static
  • Framework-Agnostic — No hard dependency on Next.js
  • Tree-ShakeablesideEffects: false, flat files, no barrels

License

MIT