New Research: Supply Chain Attack on Axios Pulls Malicious Dependency from npm.Details →
Socket
Book a DemoSign in
Socket

@classytic/arc-next

Package Overview
Dependencies
Maintainers
1
Versions
8
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@classytic/arc-next

React + TanStack Query SDK for Arc resources

latest
Source
npmnpm
Version
0.3.1
Version published
Maintainers
1
Created
Source

@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

ImportPurpose"use client"
@classytic/arc-nextRoot — same as /hooks (createCrudHooks, configureNavigation)Yes
@classytic/arc-next/clientconfigureClient, configureAuth, createClient, handleApiRequest, createQueryString, ArcApiError, isArcApiError, getAuthMode, getAuthContextNo
@classytic/arc-next/apiBaseApi, createCrudApi, response types, type guardsNo
@classytic/arc-next/querycreateQueryKeys, createCacheUtils, useListQuery, useDetailQueryYes
@classytic/arc-next/mutationconfigureToast, useMutationWithTransition, useOptimisticMutationYes
@classytic/arc-next/hookscreateCrudHooks, configureNavigationYes
@classytic/arc-next/query-clientgetQueryClient (SSR-safe singleton)No
@classytic/arc-next/prefetchcreateCrudPrefetcher, 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:

MethodSignature
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 });
PresetstaleTimerefetchInterval
realtime20s30s
frequent1min
stable5min
static10min

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
  }
);

Navigate with cache prefill

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

Keywords

react

FAQs

Package last updated on 25 Mar 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts