refine.dev integration

refine is a React meta-framework for admin panels and internal tools, built around a DataProvider interface: one small object with a fixed set of methods (getList, getOne, create, update, deleteOne, …) that every refine hook and component calls through instead of talking to your API directly.
Most readers want @cratestack/refine instead of this guide. It ships a tested DataProvider for both transports — createCratestackDataProvider (REST) and createCratestackRpcDataProvider (RPC) — covering everything below: pagination, the filter-operator mapping, non-id primary keys, @version optimistic locking, and bulk operations. Pair it with generate-typescript --refine, which emits the resource manifest for your schema, and you write no dataProvider code at all.This guide is the hand-wired version of the same thing. Read it when you need to understand what the package does under the hood, or when you want to adapt the approach rather than take the dependency.

Using the package

Four variations, in the order you’d reach for them. All four assume cratestack generate-typescript --schema schema.cstack --out ./generated --refine, which emits generated/src/refine.ts alongside the client.

REST

RPC

Same shape — a different factory, and the generated manifest is typed RpcResourceMap instead of ResourceMap. Consumer code is otherwise identical, which is deliberate:
This client defaults to the native @cratestack/cbor codec unless the schema was generated with --no-native-cbor — see The CBOR codec for what that means for a server whose CodecSet doesn’t include CborCodec.

RPC with logging and batching

RPC clients accept a links chain. createBatchLink collapses calls fired in the same tick into one POST /rpc/batch — which is what refine’s getMany does, so a table with relation columns goes from N requests to one:
Batching never weakens @version optimistic locking. createBatchLink partitions queued calls by their full transport envelope — headers included — so two updates carrying different If-Match values can’t be merged into one request with one header set. They go out as separate requests automatically. The upside is on reads: getList/getMany collapse; version-carrying writes correctly do not.

RPC over axios

createAxiosRuntime adapts an axios instance to the fetch signature, so axios becomes the transport while the links and the generated client keep speaking Request/Response. Reach for it when you already have an axios instance carrying interceptors — auth refresh, retries, a corporate proxy agent:
@cratestack/runtime-fetch’s createFetchRuntime is the drop-in equivalent when you want the default transport with explicit options — the two are interchangeable in the fetch slot. Every method name, type, and request shape below is checked against the real generated client and server. This guide covers REST-transport schemas only (generate-typescript’s default transport). RPC-transport clients expose an equivalent per-model API (see TypeScript client generation), but the query-string filter convention this guide’s getList relies on is REST-specific — an RPC dataProvider needs its own filter-mapping layer and isn’t covered here.

Prerequisites

Before wiring a resource, check three things against its .cstack model: Generate the client the usual way:
The examples below use a Widget model (plain CRUD) and a Ledger model (@@paged, @version) to show both the simple and the crux-of-this-guide cases:

The shape of the problem

A generated model API class (client.widgets, client.ledgers, …) has list/get/create/update/delete — see Using the REST client for the full generated surface. refine’s DataProvider interface is shaped similarly but not identically: different method names, a pagination/filter/sort object instead of a query string, and an id field it assumes every record has. The dataProvider below is the adapter layer between the two. Structural note: each generated model class (WidgetApi, LedgerApi, …) is its own concrete TypeScript class, not an implementation of a shared interface the package exports — there’s no generated common type to write one generic dataProvider function against across every resource. The ModelApi interface below is hand-written to match the real generated shape closely enough to type-check against it; loosen or drop it if your tsconfig is stricter than this guide’s example. This exact gap — one dataProvider per app instead of one per framework — is what @cratestack/refine closes generically; this section is what it does for you.
There’s no runtime introspection endpoint that hands back “which models are @@paged, which have @version, what’s the primary key field” — the generated client carries no such metadata object. Those facts live in the .cstack schema and in the generated client’s TypeScript types, and nowhere else at runtime, so ResourceConfig has to be written down somewhere. Let the generator write it. Pass --refine to cratestack generate-typescript and it emits an extra src/refine.ts alongside the client, holding exactly this map:
--refine is additive: every other generated file is byte-identical with and without it. It requires a REST schema and the default preset — the RPC clients don’t share the REST client’s list(options) / CratestackFetchQuery shape, and the /swr layout emits free functions rather than a client class for a resource to bind to. Writing the map by hand stays fully supported — it is a plain object literal, and it’s what the generated file contains:
The difference is drift. A model that later gains @version, or one whose @id isn’t called id, updates itself on the next generate-typescript run; the hand-written copy doesn’t, and gets it wrong silently — a stale versionField means writes stop sending If-Match and lose optimistic-locking, with no error anywhere.

Pagination

refine’s Pagination is { currentPage?: number; pageSize?: number; mode?: "client" | "server" | "off" } as of @refinedev/core v5 (what npm install @refinedev/core gives you today — verified against the package’s own shipped .d.ts). If your project is still on the v4 major, the same field is named current instead — v5 renamed currentcurrentPage; nothing else about this section changes between the two. cratestack’s list route takes limit/offset and, only for a @@paged model, returns totalCount alongside the items (Page<T> { items, totalCount, pageInfo }, mirroring cratestack_core::page::{Page, PageInfo}crates/cratestack-client-typescript/templates/src/models.ts.j2). The mapping:

Pagination requirement

A resource’s model must declare @@paged, or refine’s pagination controls silently lie. totalCount is only ever computed and emitted for a @@paged model’s list route — the token-generation gate is literally if !paged { return quote!{}; } for the total-count query (crates/cratestack-macros/src/axum/model/prep/list_logging.rs). A non-@@paged model’s list() returns a bare Widget[], no totalCount at all, and (this is the trap) limit/offset still work on it — every list route enforces the same MAX_LIST_LIMIT regardless of @@paged (see Pagination). So a non-paged resource wired into getList naively will fetch a real page 2, get back 10 rows, report total: 10 because that’s all you have to count, and refine’s pagination UI will conclude there’s no page 3 — even though there is one. Either add @@paged to the model, or configure that refine resource with pagination: { mode: "off" } and treat the (capped, up to MAX_LIST_LIMIT) full array as one page. Don’t wire page controls to a non-@@paged resource and assume total is trustworthy — it isn’t.

Filters

refine’s filter operators map onto the generated list route’s field__operator=value query convention almost one to one — this is the same operator set the generated TypeScript client’s shared filter interfaces expose (EqualityFilter<V> { eq, ne, in, isNull }, ComparableFilter<V> extends EqualityFilter<V> { lt, lte, gt, gte }, StringFilter extends ComparableFilter<string> { contains, startsWith }crates/cratestack-client-typescript/templates/src/models.ts.j2), because both are generated from the same per-field arm table (crates/cratestack-macros/src/axum/filter_arms.rs::generate_query_filter_arm). A bare field=value query param means eq; every other operator is field__<operator>=value: Caveat that’s easy to miss: eq/ne/in/lt/lte/gt/gte are only wired for required (non-nullable) fields. A nullable field (weight Int?) only ever gets contains/startsWith (if it’s a string) and isNull — the codegen arm for the comparison/equality operators is gated on field.ty.arity == TypeArity::Required (crates/cratestack-macros/src/axum/filter_arms.rs). Filtering a nullable field by exact value needs a workaround on your side (a generated column, a NOT NULL companion field) — there’s no server-side operator for it today. refine operators with no cratestack equivalent must fail loudly, not silently drop the filter. endswith, between, nin, containss (and every other operator/combinator not in the table above, including refine’s or/and conditional-filter groups — the query convention here is a flat AND of per-field predicates only) have no server-side translation. Silently dropping one of these would make the UI show an unfiltered result set as if it were filtered — worse than an error, because nothing signals the data is wrong:
If you need contains/startsWith/comparison filtering across a field combination this convention can’t express — an OR group, a nullable field’s exact-match filter — cratestack’s typed FindMany<Model> argument (<Model>Where/<Model>FindMany, the same EqualityFilter/ComparableFilter/StringFilter shapes as a structured JSON body instead of query-string suffixes) is a procedure-only mechanism, not something the plain list() route accepts — see Search with Filters. Wiring refine to a FindMany<Model>-backed procedure instead of the plain list route is a legitimate escape hatch, but it’s a different getList implementation (calling client.procedures.searchX(...) instead of client.x.list(...)), not covered further here.

Primary keys

refine assumes every record has an id: BaseKey (string | number). cratestack’s @id can be on any field, any scalar type. Mapping is one direction on read, the identity function on write, because the value space is the same — only the property name differs:
  • Read (cratestack → refine): attach a synthetic id alongside the real field, so refine’s row-selection/detail-view machinery has something to key off:
  • Write (refine → cratestack): getOne/update/deleteOne all receive refine’s synthesized id, which is exactly the primary key’s value — pass it straight through as the id argument to .get()/ .update()/.delete(), no translation needed:
    The one place this bites: a <Create> form’s fields must be named after the schema’s real primary-key field (sku, not id) — the synthetic id only exists on records that already came back from the server; a create payload has no record yet to synthesize it from.

Optimistic locking (the crux)

This is the single most important correctness point in this guide. A @version model requires If-Match on both PATCH (update) and DELETE — cratestack#519, closed by cratestack#538, which landed the delete-side enforcement delete_if_match_decl/delete_if_match_apply deliberately mirroring the update path token-for-token (crates/cratestack-macros/src/axum/model/prep/etag.rs). Missing or stale If-Match on either verb returns 412 Precondition Failed and leaves the row untouched. See Optimistic Locking for the full contract (ETag/If-Match format is a quoted integer, e.g. If-Match: "3"crates/cratestack-axum/src/headers/etag.rs). refine’s update/deleteOne hooks fetch the record before editing it (useOne/useShow populate the edit form; a list/detail view is usually on screen before a delete button is clicked), so the version is available by the time a mutation fires — it just isn’t part of refine’s UpdateParams/DeleteOneParams by default. Thread it through a small version cache the dataProvider maintains itself, populated by every read and write that returns a fresh record:
Wired into update/deleteOne, with a 412 surfaced as a real, distinguishable conflict rather than a generic failure:
CratestackHttpError (status, response, payload) is generated into runtime.ts and re-exported from the package root (crates/cratestack-client-typescript/templates/src/rest-runtime.ts.j2). 412’s response body is the standard CratestackErrorResponse { code: "PRECONDITION_FAILED", message, details } envelope (crates/cratestack-core/src/error.rs) — checking error.status === 412 rather than pattern-matching payload.code is the more robust test, since the status is set unconditionally by the runtime’s !response.ok branch regardless of codec. create and getOne/getList/getMany should also call rememberVersion on their own responses, so a versioned resource’s cache stays populated after every round trip, not just after an update:

getMany

getMany is optional on DataProvider — refine falls back to it only if you implement it. Because the in operator from the filters table applies to any required field, including the primary key, getMany is a single list() call rather than N getOne calls:

create

config.api.create is only present on the generated class in the first place when the model declares a create policy — see Policy-denied operations below for the case where the method exists but a particular caller still can’t use it.

Procedures as custom

A cratestack procedure call has no other home in a DataProvider — map refine’s custom onto client.procedures.<name>(...), using meta.procedure to name which one:
Called with the procedure’s own declared argument shape as payloadpublishPost(args: PublishPostInput) generates PublishPostArgs { args: PublishPostInput } (TypeScript client generation § Procedures), so:

Running example

examples/react-vite-refine is a real refine.dev admin app wired through @cratestack/refine against a generated, stateful WireMock backend — no database, no hand-written server. The chain is schema → generate-typescript --refinegenerate-wiremockcreateCratestackDataProvider → a live admin UI, with a Post model exercising @@paged + @version end to end: create, list, update, delete, and a stale If-Match correctly rejected with 412, all against a running container. It’s a useful reference for the packaged path this guide points to above, not the hand-wired one — but the wiring pattern (generated manifest in, createCratestackDataProvider out) is identical either way. Two honest limits worth knowing before treating it as a template:
  • No sorting, filtering, or pagination controls. cratestack-mock-wiremock’s generated stubs ignore field__operator=value, sort, limit, and offset entirely — every list response is the complete, unfiltered collection. The example is built directly on @refinedev/core’s headless hooks with pagination: { mode: "off" } on every list call specifically to avoid rendering controls that would appear to work and silently do nothing. This is a limitation of the mock, not of @cratestack/refine itself — see Generating WireMock stubs for what it does and doesn’t implement, and @cratestack/refine’s own test suite for pagination/filter/sort logic proven against a fake server that actually implements it.
  • create never honors a client-submitted primary key against the mock — it always fabricates its own id, so a create form’s submitted value is silently discarded and every follow-up call must use the id the mock actually returned. This is specific to testing against generate-wiremock’s stubs; a real cratestack-pg server honors a client-supplied @id.

Gaps: honest limitations

Three things a refine app might reach for that aren’t wired today — stated plainly rather than hand-waved: No liveProvider — the generated TypeScript client has no SSE consumer. The server has a real SSE subscription surface (GET /rpc/subscribe/{op_id}, crates/cratestack-macros/src/include/server/rpc_module/subscribe.rs, exercised end-to-end by crates/cratestack-pg/tests/rpc_subscribe_sse.rs), but the TypeScript client templates contain no EventSource/text/event-stream consumer anywhere in crates/cratestack-client-typescript/templates/ — a grep for EventSource/text/event-stream across that whole directory comes back empty. refine’s liveProvider (real-time list/detail updates via a subscription) is not wireable today against a generated TypeScript client. Poll instead (refetchInterval on the relevant query), or hand-roll an EventSource against the RPC subscribe endpoint yourself if a schema uses transport rpc. Bulk operations aren’t exposed on the generated client. update_many/delete_many exist server-side (crates/cratestack-sqlx/src/delegate/model.rs), and POST /rpc/batch exists for RPC-transport schemas, but the REST client’s per-model class only ever generates list/get/create/update/delete — no updateMany/deleteMany wrapper, and the RPC dispatch table’s op verbs are hardcoded to ["list", "get", "create", "update", "delete"] (crates/cratestack-macros/src/transport/rpc.rs). refine’s createMany/updateMany/deleteMany are optional on DataProvider — leave them unimplemented (refine falls back to sequential single-record calls in the hooks that need them, at the cost of N round trips instead of one, and no cross-record atomicity), or implement them yourself as Promise.all(...) over the single-record methods with the same caveat. Policy-denied operations still generate a working-looking client method. Route suppression — hiding a client method entirely when a caller’s policy can never satisfy it — is a designed-but-unimplemented feature: docs/design/route-suppression.md is explicit that cratestack#514 is a spike only (“Not implemented. No implementation may merge under #514”). What actually gates a generated create() method’s presence is only whether the model declares any @@allow("create", ...) at all (crates/cratestack-client-typescript/src/types.rs::model_allows_create) — not whether the predicate can ever evaluate true for the caller in question. A model with @@allow("create", auth().role == "admin") still gets a .create() method on the generated client for every caller, and a refine <Create> button wired to that resource will render for a non-admin user and fail with 403 the moment they submit. Until route suppression ships, declare such resources with create: false/edit: false/delete: false (whichever verbs your policy actually denies) in refine’s resources config for the caller roles that can’t use them — don’t rely on the generated client’s method presence as a proxy for “the current caller is allowed to do this.”

Full example

Everything above assembled into one dataProvider:
(getList, getOne, getMany, create, update, deleteOne, custom, withRefineId, toQueryFilters, toSortQuery, rememberVersion, ifMatchHeaders, toRefineError are the functions defined section by section above — assemble them into one module in that order.)

See also

  1. @cratestack/refine — the packaged version of this entire guide, for both REST and RPC schemas
  2. TypeScript client generation — the generated client surface this guide adapts, including --refine
  3. Optimistic Locking — the full @version/If-Match/ETag contract
  4. Pagination@@paged, Page<T>, MAX_LIST_LIMIT
  5. Search with Filters — FindMany<Model> — the typed, procedure-only filter argument for cases the query-string convention can’t express
  6. RPC transport — if your schema uses transport rpc instead of REST
  7. Generating WireMock stubs — the mock backend examples/react-vite-refine runs against, including what it does and doesn’t cover