
Security News
Axios Maintainer Confirms Social Engineering Attack Behind npm Compromise
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.
@classytic/arc-next
Advanced tools
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+
npm install @classytic/arc-next
Peer dependencies:
npm install react@^19 @tanstack/react-query@^5
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);
| 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).
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" }
);
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",
});
"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>
);
}
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 tokenorganizationId — sent as x-organization-id headerheaderOptions — additional headers merged into requestsignal — AbortSignal for request cancellationrevalidate / tags / cache — Next.js fetch extensionscreateQueryString(params)MongoKit-compatible query string builder:
field[in]=a,b,cpopulateOptions → populate[path][select]=field1,field2null → field=nullcreateCrudApi<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 }
);
tenant scope, otherwise → super-admin)docs/data/items/results formatsoptions.public: true — enables query without tokenselect 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",
});
id is null (conditional fetching){ data: T } wrapperselect 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}`) }
);
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 });
hasMore/next) and offset (hasNext/page) paginationitems across all pagesuseListuseUpload(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",
});
query is emptyapi.search to be defineduseCustomMutation<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" },
});
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)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.
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?)
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!" },
});
}
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 | — |
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.
By default, configureClient() sets a single global baseUrl. Use createClient() when your app talks to multiple backends.
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,
});
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,
});
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,
});
const data = await analyticsClient.request("GET", "/api/stats");
const result = await analyticsClient.request("POST", "/api/events", {
body: { type: "page_view" },
});
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";
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
}
}
// 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 });
const { items } = useProducts(null, {}, { public: true });
const { item } = useProduct(selectedId, token); // disabled when selectedId is null
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);
// All requests from this API include x-arc-scope header
const adminApi = createCrudApi("users", {
headers: { "x-arc-scope": "platform" },
});
createCrudApi + createCrudHooks generates typed API clients and React Query hooksx-organization-id header + scoped list query keys. Backend controls the tenant field name and access enforcement.docs/data/items/results + any custom key, offset/keyset/aggregate paginationuseMutationWithTransition wraps invalidation in startTransitionauthMode: 'cookie' for Better Auth, 'bearer' for token auth, configurable credentials policycreateCrudPrefetcher + dehydrate for server component data loadingcreateClient() for multiple API backends side by sideconfigureToast() — use sonner, react-hot-toast, or anythingconfigureNavigation() — use Next.js, React Router, or any routergetQueryClient() — singleton in browser, new per request on serverconfig.headers on createCrudApi merged into every requestselect 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 levelQUERY_CONFIGS.realtime/frequent/stable/staticsideEffects: false, flat files, no barrelsMIT
FAQs
React + TanStack Query SDK for Arc resources
We found that @classytic/arc-next demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

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.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.

Security News
The Axios compromise shows how time-dependent dependency resolution makes exposure harder to detect and contain.