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-nextPeer dependencies:
npm install react@^19 @tanstack/react-query@^5Setup
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 tokenorganizationId— sent asx-organization-idheaderheaderOptions— additional headers merged into requestsignal— AbortSignal for request cancellationrevalidate/tags/cache— Next.js fetch extensions
createQueryString(params)
MongoKit-compatible query string builder:
- Arrays →
field[in]=a,b,c populateOptions→populate[path][select]=field1,field2null→field=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 →
tenantscope, otherwise →super-admin) - Normalizes pagination from
docs/data/items/resultsformats - 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
idis 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 }); // replaceSets 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
itemsacross 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
queryis empty - Requires
api.searchto 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 nullPer-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 Factory —
createCrudApi+createCrudHooksgenerates 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-idheader + 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 Transitions —
useMutationWithTransitionwraps invalidation instartTransition - Cookie & Bearer Auth —
authMode: 'cookie'for Better Auth,'bearer'for token auth, configurablecredentialspolicy - SSR Prefetch —
createCrudPrefetcher+dehydratefor server component data loading - Multi-Client —
createClient()for multiple API backends side by side - Pluggable Toast —
configureToast()— use sonner, react-hot-toast, or anything - Pluggable Navigation —
configureNavigation()— use Next.js, React Router, or any router - SSR-Safe QueryClient —
getQueryClient()— singleton in browser, new per request on server - Per-Instance Headers —
config.headersoncreateCrudApimerged into every request selectTransform — Transform raw API data before it reaches components (useList,useDetail)onSettledLifecycle — 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 Presets —
QUERY_CONFIGS.realtime/frequent/stable/static - Framework-Agnostic — No hard dependency on Next.js
- Tree-Shakeable —
sideEffects: false, flat files, no barrels
License
MIT