@classytic/arc-next 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @classytic/arc-next
2
2
 
3
- React + TanStack Query SDK for Arc resources. Production-grade CRUD hooks with optimistic updates, multi-tenant scoping, and pluggable configuration.
3
+ 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.
4
+
5
+ **Requires:** React 19+, TanStack React Query 5+
4
6
 
5
7
  ## Install
6
8
 
@@ -19,31 +21,49 @@ npm install react@^19 @tanstack/react-query@^5
19
21
  Call the configuration functions once at app init (e.g., in your root providers):
20
22
 
21
23
  ```ts
22
- import { configureClient } from "@classytic/arc-next/client";
24
+ import { configureClient, configureAuth } from "@classytic/arc-next/client";
23
25
  import { configureToast } from "@classytic/arc-next/mutation";
24
26
  import { configureNavigation } from "@classytic/arc-next/hooks";
25
27
  import { toast } from "sonner";
26
28
  import { useRouter } from "next/navigation";
27
29
 
28
- // Required — sets the API base URL
30
+ // Required — sets the API base URL and auth mode
29
31
  configureClient({
30
32
  baseUrl: process.env.NEXT_PUBLIC_API_URL!,
33
+ authMode: "cookie", // 'cookie' for Better Auth, 'bearer' for token auth (default)
31
34
  internalApiKey: process.env.NEXT_PUBLIC_INTERNAL_API_KEY, // optional
32
35
  });
33
36
 
34
- // Optional — pluggable toast (defaults to console)
35
- configureToast({
36
- success: toast.success,
37
- error: toast.error,
37
+ // Optional — auto-inject org context into queries/mutations
38
+ configureAuth({
39
+ getOrgId: () => activeOrgId, // return current org ID
40
+ getToken: () => null, // null for cookie auth (token only for bearer)
38
41
  });
39
42
 
43
+ // Optional — pluggable toast (defaults to console)
44
+ configureToast({ success: toast.success, error: toast.error });
45
+
40
46
  // Optional — enables useNavigation() routing (defaults to cache-only)
41
47
  configureNavigation(useRouter);
42
48
  ```
43
49
 
44
- ## Usage
50
+ ## Subpath Exports
45
51
 
46
- ### 1. Define your API
52
+ | Import | Purpose | `"use client"` |
53
+ | ----------------------------------- | ---------------------------------------------------------------------------- | :-------------: |
54
+ | `@classytic/arc-next/client` | `configureClient`, `configureAuth`, `createClient`, `handleApiRequest`, `createQueryString`, `ArcApiError`, `isArcApiError`, `getAuthMode`, `getAuthContext` | No |
55
+ | `@classytic/arc-next/api` | `BaseApi`, `createCrudApi`, response types, type guards | No |
56
+ | `@classytic/arc-next/query` | `createQueryKeys`, `createCacheUtils`, `createListQuery`, `createDetailQuery`| Yes |
57
+ | `@classytic/arc-next/mutation` | `configureToast`, `useMutationWithTransition`, `createOptimisticMutation` | Yes |
58
+ | `@classytic/arc-next/hooks` | `createCrudHooks`, `configureNavigation` | Yes |
59
+ | `@classytic/arc-next/query-client` | `getQueryClient` (SSR-safe singleton) | No |
60
+ | `@classytic/arc-next/prefetch` | `createCrudPrefetcher`, `dehydrate` (SSR prefetch) | No |
61
+
62
+ No barrel index — every file is its own entry point. Tree-shakeable (`sideEffects: false`).
63
+
64
+ ## Quick Start
65
+
66
+ ### 1. Define API
47
67
 
48
68
  ```ts
49
69
  import { createCrudApi } from "@classytic/arc-next/api";
@@ -98,21 +118,20 @@ export function ProductsPage() {
98
118
 
99
119
  const { create, remove, isCreating } = useProductActions();
100
120
 
101
- const handleCreate = async () => {
102
- await create({ data: { name: "New Product", price: 29.99 } });
103
- };
104
-
105
121
  if (isLoading) return <div>Loading...</div>;
106
122
 
107
123
  return (
108
124
  <div>
109
- <button onClick={handleCreate} disabled={isCreating}>
125
+ <button
126
+ onClick={() => create({ data: { name: "Widget", price: 9.99 } })}
127
+ disabled={isCreating}
128
+ >
110
129
  Add Product
111
130
  </button>
112
- {items.map((product) => (
113
- <div key={product._id}>
114
- {product.name} — ${product.price}
115
- <button onClick={() => remove({ id: product._id })}>Delete</button>
131
+ {items.map((p) => (
132
+ <div key={p._id}>
133
+ {p.name} — ${p.price}
134
+ <button onClick={() => remove({ id: p._id })}>Delete</button>
116
135
  </div>
117
136
  ))}
118
137
  {pagination && <span>{pagination.total} total</span>}
@@ -121,50 +140,282 @@ export function ProductsPage() {
121
140
  }
122
141
  ```
123
142
 
124
- ## Subpath Exports
143
+ ## API Reference
125
144
 
126
- | Import | Purpose | `"use client"` |
127
- |---|---|:-:|
128
- | `@classytic/arc-next/client` | `configureClient`, `handleApiRequest`, `createQueryString` | No |
129
- | `@classytic/arc-next/api` | `BaseApi`, `createCrudApi`, response types, type guards | No |
130
- | `@classytic/arc-next/query` | `createQueryKeys`, `createCacheUtils`, `createListQuery`, `createDetailQuery` | Yes |
131
- | `@classytic/arc-next/mutation` | `configureToast`, `useMutationWithTransition`, `createOptimisticMutation` | Yes |
132
- | `@classytic/arc-next/hooks` | `createCrudHooks`, `configureNavigation` | Yes |
133
- | `@classytic/arc-next/query-client` | `getQueryClient` (SSR-safe singleton) | No |
145
+ ### `configureClient(config)`
134
146
 
135
- ## Features
147
+ ```ts
148
+ configureClient({
149
+ baseUrl: string; // Required — API base URL
150
+ authMode?: 'cookie' | 'bearer'; // Default: 'bearer'
151
+ internalApiKey?: string; // Optional — sent as x-internal-api-key header
152
+ defaultHeaders?: Record<string, string>; // Optional — merged into every request
153
+ });
154
+ ```
136
155
 
137
- - **CRUD Factory**`createCrudApi` + `createCrudHooks` generates typed API clients and React Query hooks
138
- - **Optimistic Updates**Create, update, delete with instant UI feedback and automatic rollback
139
- - **Multi-Tenant Scoping** — `organizationId` in headers + scoped query keys
140
- - **Pagination Normalization** — Handles `docs`/`data`/`items` response formats, offset/keyset/aggregate pagination
141
- - **Detail Cache Prefilling** — List results auto-populate detail query cache
142
- - **React 19 Transitions** — `useMutationWithTransition` wraps invalidation in `startTransition`
143
- - **Pluggable Toast** — `configureToast()` — use sonner, react-hot-toast, or anything
144
- - **Pluggable Navigation** — `configureNavigation()` — use Next.js, React Router, or any router
145
- - **SSR-Safe QueryClient** — `getQueryClient()` — singleton in browser, new per request on server
146
- - **Framework-Agnostic** — No hard dependency on Next.js
147
- - **Tree-Shakeable** — `sideEffects: false`, flat files, no barrels
156
+ - `authMode: 'bearer'` (default) requires a token for authenticated requests; queries are disabled until a token is provided
157
+ - `authMode: 'cookie'`auth via HTTP-only cookies (e.g. Better Auth); queries are always enabled, no token needed
148
158
 
149
- ## Custom Mutations
159
+ Must be called before any API requests. Throws if not configured.
150
160
 
151
- For operations beyond CRUD (publish, schedule, upload), use the mutation factories directly:
161
+ ### `configureAuth(config)`
152
162
 
153
163
  ```ts
154
- import { useMutationWithTransition } from "@classytic/arc-next/mutation";
155
- import { productsApi, productKeys } from "./products";
164
+ configureAuth({
165
+ getToken?: () => string | null; // For bearer auth — return access token
166
+ getOrgId?: () => string | null; // Return active organization ID
167
+ });
168
+ ```
156
169
 
157
- export function usePublishProduct() {
158
- return useMutationWithTransition({
159
- mutationFn: (id: string) =>
160
- productsApi.request("POST", `${productsApi.baseUrl}/${id}/publish`),
161
- invalidateQueries: [productKeys.all],
162
- messages: { success: "Product published!", error: "Failed to publish" },
170
+ Auto-injects `token` and `organizationId` into queries/mutations. Hooks use the new signature (no explicit token param) — legacy signature still works.
171
+
172
+ ### `handleApiRequest<T>(method, endpoint, options?)`
173
+
174
+ Universal fetch wrapper. Handles JSON, PDF, image, CSV, and text responses.
175
+
176
+ ```ts
177
+ const result = await handleApiRequest<ApiResponse<User>>("GET", "/api/users/me");
178
+ const list = await handleApiRequest<PaginatedResponse<Product>>("GET", "/api/products?page=1");
179
+ ```
180
+
181
+ **Options:**
182
+ - `body` — request body (auto-serializes JSON, passes FormData as-is)
183
+ - `token` — Bearer token
184
+ - `organizationId` — sent as `x-organization-id` header
185
+ - `headerOptions` — additional headers merged into request
186
+ - `signal` — AbortSignal for request cancellation
187
+ - `revalidate` / `tags` / `cache` — Next.js fetch extensions
188
+
189
+ ### `createQueryString(params)`
190
+
191
+ MongoKit-compatible query string builder:
192
+ - Arrays → `field[in]=a,b,c`
193
+ - `populateOptions` → `populate[path][select]=field1,field2`
194
+ - `null` → `field=null`
195
+
196
+ ### `createCrudApi<TDoc, TCreate, TUpdate>(entity, config?)`
197
+
198
+ Creates a typed API client instance.
199
+
200
+ ```ts
201
+ const api = createCrudApi<Product, CreateProduct>("products", {
202
+ basePath: "/api", // default: "/api/v1"
203
+ defaultParams: { limit: 20 },
204
+ cache: "no-store", // default
205
+ headers: { // optional — sent with every request from this instance
206
+ "x-arc-scope": "platform", // e.g. for superadmin elevation
207
+ },
208
+ });
209
+ ```
210
+
211
+ **Methods:**
212
+
213
+ | Method | Signature |
214
+ |---|---|
215
+ | `getAll` | `({ token?, organizationId?, params? }) → PaginatedResponse<T>` |
216
+ | `getById` | `({ id, token?, organizationId?, params? }) → ApiResponse<T>` |
217
+ | `create` | `({ data, token?, organizationId? }) → ApiResponse<T>` |
218
+ | `update` | `({ id, data, token?, organizationId? }) → ApiResponse<T>` |
219
+ | `delete` | `({ id, token?, organizationId? }) → DeleteResponse` |
220
+ | `upload` | `({ data: FormData, id?, path?, token?, organizationId? }) → ApiResponse<T>` |
221
+ | `search` | `({ searchParams?, params?, token?, organizationId? }) → PaginatedResponse<T>` |
222
+ | `findBy` | `({ field, value, operator?, token?, organizationId? }) → PaginatedResponse<T>` |
223
+ | `request` | `(method, endpoint, { data?, params?, token? }) → T` |
224
+
225
+ **`prepareParams(params)`** — processes query params: critical filters (`organizationId`, `ownerId`) preserved as null, arrays → `field[in]`, pagination parsed to int.
226
+
227
+ ### `createCrudHooks<T, TCreate, TUpdate>(config)`
228
+
229
+ Factory that returns everything you need:
230
+
231
+ ```ts
232
+ const {
233
+ KEYS, cache,
234
+ useList, useDetail, useInfiniteList,
235
+ useActions, useUpload, useSearch, useCustomMutation,
236
+ useNavigation,
237
+ } = createCrudHooks<Product, CreateProduct>({
238
+ api: productsApi, // from createCrudApi()
239
+ entityKey: "products", // TanStack Query key prefix
240
+ singular: "Product", // for toast messages
241
+ defaults: { // optional
242
+ staleTime: 60_000,
243
+ messages: { createSuccess: "Product added!" },
244
+ },
245
+ callbacks: { // optional
246
+ onCreate: {
247
+ onSuccess: (data) => console.log("Created:", data),
248
+ onSettled: (data, error) => console.log("Done"),
249
+ },
250
+ },
163
251
  });
164
- }
165
252
  ```
166
253
 
167
- ## QueryClient Setup
254
+ **Returned hooks:**
255
+
256
+ #### `useList(token, params?, options?)`
257
+
258
+ ```ts
259
+ const { items, pagination, isLoading, isFetching, refetch } = useList(
260
+ token,
261
+ { organizationId: "org-123", status: "active" },
262
+ { public: true, staleTime: 30_000, prefillDetailCache: true }
263
+ );
264
+ ```
265
+
266
+ - Auto-scopes query keys by `organizationId` (tenant vs super-admin)
267
+ - Normalizes pagination from `docs`/`data`/`items`/`results` formats
268
+ - Prefills detail cache from list results (skips re-fetch on navigate)
269
+ - `options.public: true` — enables query without token
270
+
271
+ **`select` transform** — transform raw API data before it reaches your component:
272
+
273
+ ```ts
274
+ const { items } = useList(token, { organizationId }, {
275
+ select: (data) => ({
276
+ ...data,
277
+ docs: data.docs.map((p) => ({ ...p, displayName: `${p.name} ($${p.price})` })),
278
+ }),
279
+ });
280
+ ```
281
+
282
+ #### `useDetail(id, token, options?)`
283
+
284
+ ```ts
285
+ const { item, isLoading } = useDetail(productId, token, {
286
+ organizationId: "org-123",
287
+ });
288
+ ```
289
+
290
+ - Disabled when `id` is null (conditional fetching)
291
+ - Extracts item from `{ data: T }` wrapper
292
+
293
+ **`select` transform:**
294
+
295
+ ```ts
296
+ const { item } = useDetail(productId, token, {
297
+ select: (data) => ({ ...data.data, fullName: `${data.data.firstName} ${data.data.lastName}` }),
298
+ });
299
+ ```
300
+
301
+ #### `useActions()`
302
+
303
+ ```ts
304
+ const { create, update, remove, isCreating, isUpdating, isDeleting, isMutating } =
305
+ useActions();
306
+
307
+ // All mutations have optimistic updates + automatic rollback on error
308
+ await create({ data: { name: "New" }, organizationId: "org-123" });
309
+ await update({ id: "123", data: { name: "Updated" } });
310
+ await remove({ id: "123" });
311
+
312
+ // Per-call callbacks
313
+ await create(
314
+ { data: { name: "New" } },
315
+ { onSuccess: (item) => navigate(`/products/${item._id}`) }
316
+ );
317
+ ```
318
+
319
+ - **Create** — optimistic: prepends to list with temp ID
320
+ - **Update** — optimistic: patches item in list + detail cache
321
+ - **Delete** — optimistic: removes from list + detail cache
322
+ - All roll back automatically on error
323
+
324
+ #### `useNavigation()`
325
+
326
+ ```ts
327
+ const navigate = useNavigation();
328
+ navigate(`/products/${id}`, product); // push + cache prefill
329
+ navigate(`/products/${id}`, product, { replace: true }); // replace
330
+ ```
331
+
332
+ Sets detail cache before navigation (instant page load, no loading spinner).
333
+ Requires `configureNavigation(useRouter)` — without it, only sets cache (no routing).
334
+
335
+ #### `useInfiniteList(token, params?, options?)`
336
+
337
+ Cursor-based infinite scrolling with automatic page aggregation:
338
+
339
+ ```ts
340
+ const { items, hasNextPage, fetchNextPage, isFetchingNextPage, isLoading } =
341
+ useInfiniteList(token, { organizationId: "org-123", limit: 20 });
342
+ ```
343
+
344
+ - Supports both keyset (`hasMore`/`next`) and offset (`hasNext`/`page`) pagination
345
+ - Returns flattened `items` across all pages
346
+ - Auto-scopes query keys like `useList`
347
+
348
+ #### `useUpload(options?)`
349
+
350
+ Upload FormData with cache invalidation:
351
+
352
+ ```ts
353
+ const { mutateAsync: upload, isPending } = useUpload({
354
+ messages: { success: "Uploaded!", error: "Upload failed" },
355
+ onSuccess: (data) => console.log("Uploaded:", data),
356
+ });
357
+
358
+ // Post to base collection URL
359
+ await upload({ data: formData });
360
+ // Post to /products/{id}/upload
361
+ await upload({ data: formData, id: "doc-123" });
362
+ // Post to /products/bulk-import (custom path takes precedence over id)
363
+ await upload({ data: formData, path: "bulk-import" });
364
+ ```
365
+
366
+ Requires `api.upload` to be defined. Throws if not available.
367
+
368
+ #### `useSearch(query, params?, options?)`
369
+
370
+ Search with automatic query key scoping:
371
+
372
+ ```ts
373
+ const { items, pagination, isLoading } = useSearch("widget", {
374
+ organizationId: "org-123",
375
+ });
376
+ ```
377
+
378
+ - Disabled when `query` is empty
379
+ - Requires `api.search` to be defined
380
+
381
+ #### `useCustomMutation<TData, TVariables>(config)`
382
+
383
+ Build custom mutations that share the entity's toast and invalidation patterns:
384
+
385
+ ```ts
386
+ const { mutateAsync: publish, isPending } = useCustomMutation({
387
+ mutationFn: (id: string) => api.request("POST", `${api.baseUrl}/${id}/publish`),
388
+ invalidateQueries: [productKeys.lists()],
389
+ messages: { success: "Published!", error: "Failed to publish" },
390
+ });
391
+ ```
392
+
393
+ ### Query Keys (`KEYS`)
394
+
395
+ ```ts
396
+ KEYS.all // ["products"]
397
+ KEYS.lists() // ["products", "list"]
398
+ KEYS.list(params) // ["products", "list", params]
399
+ KEYS.details() // ["products", "detail"]
400
+ KEYS.detail(id) // ["products", "detail", id]
401
+ KEYS.custom("stats", orgId) // ["products", "stats", orgId]
402
+ KEYS.scopedList("tenant", params) // ["products", "list", { _scope: "tenant", ...params }]
403
+ ```
404
+
405
+ ### Cache Utilities (`cache`)
406
+
407
+ ```ts
408
+ await cache.invalidateAll(queryClient);
409
+ await cache.invalidateLists(queryClient);
410
+ await cache.invalidateDetail(queryClient, id);
411
+ cache.setDetail(queryClient, id, data);
412
+ cache.getDetail(queryClient, id); // T | undefined
413
+ cache.removeDetail(queryClient, id);
414
+ ```
415
+
416
+ ### `getQueryClient(overrides?)`
417
+
418
+ SSR-safe singleton. Server: new per request. Browser: reuses singleton.
168
419
 
169
420
  ```ts
170
421
  import { getQueryClient } from "@classytic/arc-next/query-client";
@@ -180,6 +431,297 @@ function Providers({ children }) {
180
431
  }
181
432
  ```
182
433
 
434
+ Defaults: `staleTime: 5min`, `gcTime: 30min`, `retry: 0`, `refetchOnWindowFocus: false`.
435
+
436
+ ## SSR Prefetch (Server Components)
437
+
438
+ Pre-populate the query cache on the server to avoid loading spinners:
439
+
440
+ ```ts
441
+ // products-prefetch.ts
442
+ import { createCrudPrefetcher } from "@classytic/arc-next/prefetch";
443
+ import { productsApi } from "@/api/products-api";
444
+
445
+ export const productsPrefetcher = createCrudPrefetcher(productsApi, "products");
446
+ ```
447
+
448
+ ```tsx
449
+ // app/products/page.tsx (server component)
450
+ import { getQueryClient } from "@classytic/arc-next/query-client";
451
+ import { dehydrate } from "@classytic/arc-next/prefetch";
452
+ import { HydrationBoundary } from "@tanstack/react-query";
453
+ import { productsPrefetcher } from "@/prefetch/products-prefetch";
454
+
455
+ export default async function ProductsPage() {
456
+ const queryClient = getQueryClient();
457
+ await productsPrefetcher.prefetchList(queryClient, { limit: 20 });
458
+
459
+ return (
460
+ <HydrationBoundary state={dehydrate(queryClient)}>
461
+ <ProductsList />
462
+ </HydrationBoundary>
463
+ );
464
+ }
465
+ ```
466
+
467
+ **Methods:** `prefetchList(queryClient, params?, options?)`, `prefetchDetail(queryClient, id, options?)`
468
+
469
+ ## Custom Mutations
470
+
471
+ For operations beyond CRUD (publish, schedule, upload):
472
+
473
+ ### `useMutationWithTransition(config)`
474
+
475
+ Mutation + React 19 `useTransition` for smooth cache invalidation:
476
+
477
+ ```ts
478
+ import { useMutationWithTransition } from "@classytic/arc-next/mutation";
479
+
480
+ export function usePublishPost() {
481
+ return useMutationWithTransition({
482
+ mutationFn: (id: string) =>
483
+ postsApi.request("POST", `${postsApi.baseUrl}/${id}/publish`),
484
+ invalidateQueries: [postKeys.all],
485
+ messages: { success: "Published!", error: "Failed to publish" },
486
+ useTransition: true, // default
487
+ showToast: true, // default
488
+ });
489
+ }
490
+ ```
491
+
492
+ Returns: `{ mutate, mutateAsync, isPending, isSuccess, isError, error, data, reset }`
493
+
494
+ ### `useMutationWithOptimistic(config)`
495
+
496
+ Mutation + optimistic updates + automatic rollback:
497
+
498
+ ```ts
499
+ import { useMutationWithOptimistic } from "@classytic/arc-next/mutation";
500
+
501
+ export function useToggleFavorite() {
502
+ return useMutationWithOptimistic({
503
+ mutationFn: ({ id, isFav }) =>
504
+ api.request("PATCH", `/api/products/${id}`, {
505
+ data: { favorite: !isFav },
506
+ }),
507
+ queryKeys: [productKeys.lists()],
508
+ optimisticUpdate: (old, { id, isFav }) =>
509
+ updateListCache(old, (items) =>
510
+ items.map((i) => (getItemId(i) === id ? { ...i, favorite: !isFav } : i))
511
+ ),
512
+ messages: { success: "Updated!" },
513
+ });
514
+ }
515
+ ```
516
+
517
+ ### Query Config Presets
518
+
519
+ ```ts
520
+ import { QUERY_CONFIGS } from "@classytic/arc-next/mutation";
521
+
522
+ // Use in useList options:
523
+ useProducts(token, {}, { ...QUERY_CONFIGS.realtime });
524
+ ```
525
+
526
+ | Preset | `staleTime` | `refetchInterval` |
527
+ | ---------- | ----------- | ------------------ |
528
+ | `realtime` | 20s | 30s |
529
+ | `frequent` | 1min | — |
530
+ | `stable` | 5min | — |
531
+ | `static` | 10min | — |
532
+
533
+ ## Low-Level Utilities
534
+
535
+ ### `updateListCache(listData, updater)`
536
+
537
+ Transforms list cache regardless of format (`docs[]`, `data[]`, `items[]`, `results[]`, or raw array).
538
+ Automatically adjusts `total`/`totalDocs` counts when items are added or removed (optimistic add/delete).
539
+
540
+ ```ts
541
+ import { updateListCache } from "@classytic/arc-next/query";
542
+
543
+ queryClient.setQueryData(KEYS.lists(), (old) =>
544
+ updateListCache(old, (items) => items.filter((i) => i.status !== "archived"))
545
+ );
546
+ ```
547
+
548
+ ### `getItemId(item)`
549
+
550
+ Extracts `_id` or `id` from any item. Returns `string | null`.
551
+
552
+ ### `normalizePagination(data)`
553
+
554
+ Converts any pagination response format to a normalized `PaginationData` object. Handles `total`/`totalDocs`, `pages`/`totalPages`, `page`/`currentPage`, `hasNext`/`hasNextPage`/`hasMore`, `hasPrev`/`hasPrevPage`.
555
+
556
+ ### `extractItems<T>(data)`
557
+
558
+ Extracts the items array from any response format — looks for `docs`, `data`, `items`, `results` fields, or returns the data directly if it's already an array.
559
+
560
+ ## Multi-Client (Multiple APIs)
561
+
562
+ By default, `configureClient()` sets a single global `baseUrl`. Use `createClient()` when your app talks to multiple backends.
563
+
564
+ ### Create isolated clients
565
+
566
+ ```ts
567
+ import { createClient } from "@classytic/arc-next/client";
568
+ import { toast } from "sonner";
569
+ import { useRouter } from "next/navigation";
570
+
571
+ const analyticsClient = createClient({
572
+ baseUrl: "https://analytics.example.com",
573
+ internalApiKey: "analytics-key",
574
+ toast: { success: toast.success, error: toast.error },
575
+ navigation: useRouter,
576
+ });
577
+ ```
578
+
579
+ ### Use with createCrudApi
580
+
581
+ Pass `client` in the config — requests go through the client's `baseUrl` instead of the global one:
582
+
583
+ ```ts
584
+ const eventsApi = createCrudApi("events", {
585
+ basePath: "/api",
586
+ client: analyticsClient,
587
+ });
588
+ ```
589
+
590
+ ### Use with createCrudHooks
591
+
592
+ Pass `client` — toast and navigation use the client's handlers instead of globals:
593
+
594
+ ```ts
595
+ const { useList, useActions } = createCrudHooks({
596
+ api: eventsApi,
597
+ entityKey: "events",
598
+ singular: "Event",
599
+ client: analyticsClient,
600
+ });
601
+ ```
602
+
603
+ ### Direct requests
604
+
605
+ ```ts
606
+ const data = await analyticsClient.request("GET", "/api/stats");
607
+ const result = await analyticsClient.request("POST", "/api/events", {
608
+ body: { type: "page_view" },
609
+ });
610
+ ```
611
+
612
+ ## Response Types
613
+
614
+ ```ts
615
+ import type {
616
+ ApiResponse, // { success, data?, message? }
617
+ PaginatedResponse, // OffsetPaginationResponse | KeysetPaginationResponse | AggregatePaginationResponse
618
+ OffsetPaginationResponse, // { docs[], page, limit, total, pages, hasNext, hasPrev }
619
+ KeysetPaginationResponse, // { docs[], limit, hasMore, next }
620
+ AggregatePaginationResponse, // same shape as offset
621
+ DeleteResponse, // { success, data?: { message?, id?, soft? } }
622
+ } from "@classytic/arc-next/api";
623
+
624
+ // Type guards
625
+ import {
626
+ isOffsetPagination,
627
+ isKeysetPagination,
628
+ isAggregatePagination,
629
+ } from "@classytic/arc-next/api";
630
+ ```
631
+
632
+ ## Error Handling
633
+
634
+ All API errors throw `ArcApiError`:
635
+
636
+ ```ts
637
+ import { ArcApiError, isArcApiError } from "@classytic/arc-next/client";
638
+
639
+ try {
640
+ await productsApi.create({ data: { name: "" } });
641
+ } catch (err) {
642
+ if (isArcApiError(err)) {
643
+ console.log(err.status); // HTTP status code
644
+ console.log(err.message); // Error message from server
645
+ console.log(err.fieldErrors); // { field: "message" } or null
646
+ console.log(err.endpoint); // Request endpoint
647
+ console.log(err.method); // HTTP method
648
+ }
649
+ }
650
+ ```
651
+
652
+ ## Common Patterns
653
+
654
+ ### Multi-tenant data fetching
655
+
656
+ ```ts
657
+ // organizationId in params → scoped query key → isolated cache per tenant
658
+ const { items } = useProducts(token, { organizationId: currentOrg });
659
+ ```
660
+
661
+ ### Public endpoints (no auth)
662
+
663
+ ```ts
664
+ const { items } = useProducts(null, {}, { public: true });
665
+ ```
666
+
667
+ ### Conditional fetching
668
+
669
+ ```ts
670
+ const { item } = useProduct(selectedId, token); // disabled when selectedId is null
671
+ ```
672
+
673
+ ### Per-call callbacks
674
+
675
+ ```ts
676
+ await create(
677
+ { data: formData, organizationId: org },
678
+ {
679
+ onSuccess: (product) => router.push(`/products/${product._id}`),
680
+ onError: (err) => setFieldErrors(err),
681
+ onSettled: (data, error) => setSubmitting(false), // fires after success or error
682
+ }
683
+ );
684
+ ```
685
+
686
+ ### Navigate with cache prefill
687
+
688
+ ```ts
689
+ const navigate = useProductNavigation();
690
+ // Prefills detail cache → no loading spinner on detail page
691
+ navigate(`/products/${product._id}`, product);
692
+ ```
693
+
694
+ ### Per-instance headers
695
+
696
+ ```ts
697
+ // All requests from this API include x-arc-scope header
698
+ const adminApi = createCrudApi("users", {
699
+ headers: { "x-arc-scope": "platform" },
700
+ });
701
+ ```
702
+
703
+ ## Features
704
+
705
+ - **CRUD Factory** — `createCrudApi` + `createCrudHooks` generates typed API clients and React Query hooks
706
+ - **Optimistic Updates** — Create, update, delete with instant UI feedback and automatic rollback
707
+ - **Multi-Tenant Scoping** — `organizationId` in headers + scoped query keys
708
+ - **Pagination Normalization** — Handles `docs`/`data`/`items`/`results` response formats, offset/keyset/aggregate pagination
709
+ - **Detail Cache Prefilling** — List results auto-populate detail query cache
710
+ - **React 19 Transitions** — `useMutationWithTransition` wraps invalidation in `startTransition`
711
+ - **Cookie & Bearer Auth** — `authMode: 'cookie'` for Better Auth, `'bearer'` for token auth
712
+ - **SSR Prefetch** — `createCrudPrefetcher` + `dehydrate` for server component data loading
713
+ - **Multi-Client** — `createClient()` for multiple API backends side by side
714
+ - **Pluggable Toast** — `configureToast()` — use sonner, react-hot-toast, or anything
715
+ - **Pluggable Navigation** — `configureNavigation()` — use Next.js, React Router, or any router
716
+ - **SSR-Safe QueryClient** — `getQueryClient()` — singleton in browser, new per request on server
717
+ - **Per-Instance Headers** — `config.headers` on `createCrudApi` merged into every request
718
+ - **`select` Transform** — Transform raw API data before it reaches components (`useList`, `useDetail`)
719
+ - **`onSettled` Lifecycle** — Callback that fires after both success and error, at factory and per-call level
720
+ - **Automatic Request Cancellation** — AbortSignal passthrough — unmounted components cancel in-flight requests automatically
721
+ - **Query Config Presets** — `QUERY_CONFIGS.realtime/frequent/stable/static`
722
+ - **Framework-Agnostic** — No hard dependency on Next.js
723
+ - **Tree-Shakeable** — `sideEffects: false`, flat files, no barrels
724
+
183
725
  ## License
184
726
 
185
727
  MIT