react-resource-view
Backends & dialects
One declaration against API Platform, Strapi or Supabase — and how to add a fourth.
What a dialect knows#
The views know a resource has rows, pages and filters. How a given backend spells those — the URL an item lives at, the query string a filter becomes, the envelope a collection arrives in, where the validation errors hide — is a dialect, set once at startup.
import { configureApi, strapiDialect } from "react-resource-view"
configureApi({
baseUrl: "https://cms.example.com",
getAuthToken: () => (isLogged() ? getUserToken() : undefined),
dialect: strapiDialect(),
})| Name | Type | Default | Description |
|---|---|---|---|
jsonLdDialect() | ApiDialectInterface | the default | API Platform and Hydra. member / totalItems, IRIs, page and itemsPerPage, Hydra violations, Mercure, CSV export. |
strapiDialect() | ApiDialectInterface | — | Strapi v4 and v5. pagination[page], filters[field][$eq], sort[0], populate, writes wrapped in data, documentId. |
supabaseDialect() | ApiDialectInterface | — | Supabase, over PostgREST. limit / offset, field=eq.value, order, the count read from Content-Range, rows addressed by their primary key. |
An API Platform application changes nothing
JSON-LD is the default, and it still goes through the client configured with configureClient — middleware, scope header and typed paths included. configureApi falls back to that client's settings when it is given none of its own, so nothing has to move.
Strapi#
import { configureApi, createViewResource, strapiDialect } from "react-resource-view"
configureApi({
baseUrl: "https://cms.example.com",
getAuthToken: () => getApiToken(),
dialect: strapiDialect(),
})
const articles = createViewResource("articles", {
path: "articles", // → /api/articles
name: "Articles",
view: {
itemsPerPage: 25,
form: { inputs: { title: { label: "Title" }, body: { label: "Body" } } },
},
})| Name | Type | Default | Description |
|---|---|---|---|
apiPath | string | "/api" | Prefix of the REST routes. A path that already carries it is left alone, so "articles" and "/api/articles" both work. |
populate | string | string[] | false | "*" | Which relations come back. Without it the relation columns of a list are empty. |
identifier | "documentId" | "id" | "documentId" | How an entry is addressed: documentId on v5, id on v4. A record without the preferred one falls back to the other. |
defaultOperator | string | "$eq" | What a plain filter value becomes. Pass "$containsi" to turn every text filter into a case-insensitive search. |
The v4 { id, attributes } envelope — and the { data } wrapper around each relation — is flattened on the way in, so article.title and article.author.name read the same on both versions and a resource declared once works against either.
No CSV export
Strapi serves no CSV endpoint, so the export button of a list hides itself rather than offering a download that would 404. Nothing to configure.
Supabase#
import { configureApi, createViewResource, supabaseDialect } from "react-resource-view"
configureApi({
baseUrl: "https://xyzcompany.supabase.co",
getAuthToken: () => getSession()?.access_token,
dialect: supabaseDialect({ apiKey: import.meta.env.VITE_SUPABASE_ANON_KEY }),
})
const articles = createViewResource("articles", {
path: "articles", // → /rest/v1/articles
name: "Articles",
view: { form: { inputs: { title: { label: "Title" } } } },
})| Name | Type | Default | Description |
|---|---|---|---|
apiKey | string | (() => string | undefined) | — | The project's anon key, sent as apikey on every request alongside the signed-in user's token. A function is accepted, for a key that only exists once the environment is read. |
primaryKey | string | "id" | How a row is addressed — PostgREST has no item route, so a single row is a filter on this column. |
select | string | "*" | Sent with every read. "*,author(*)" embeds a relation, the way Supabase joins. |
schema | string | public | Sent as Accept-Profile and Content-Profile, for a table outside public. |
defaultTextOperator | "eq" | "ilike" | "like" | "eq" | What a plain text filter becomes. "ilike" turns the filter bar into a case-insensitive search bar. |
restPath | string | "/rest/v1" | Prefix of the REST routes. |
The count comes from a header
PostgREST counts only when asked, and answers in Content-Range. The dialect asks — Prefer: count=exact — and keeps the total in the collection it hands the views, since the header is long gone by the time the pagination renders. A list whose API reports no total renders no pagination rather than inventing a page count.
Two backends at once#
A resource may carry a dialect of its own, which wins over the configured one:
// Most of the application is on Strapi…
configureApi({ baseUrl, dialect: strapiDialect() })
// …and this one table is not.
const invoices = createViewResource("invoices", {
path: "invoices",
dialect: supabaseDialect({ apiKey }),
})The dialect is read when the resource is built
createViewResource builds the resource's repository as it runs, so configureApi has to come first — in a file your entry point imports before it declares any resource.
Filters, pages and sorts#
They are written once, in the package's own vocabulary, and the dialect translates them. Three keys are reserved; everything else in a filter is a field of the resource.
view: {
itemsPerPage: 25,
defaultFilter: { status: "published", order: { createdAt: "desc" } },
formFilter: { inputs: { title: { label: "Title" } } },
}| Name | Type | Default | Description |
|---|---|---|---|
page | number | 1 | 1-based page. pagination[page] on Strapi, an offset on Supabase, page on API Platform. |
itemsPerPage | number | view.itemsPerPage | Rows per page. pagination[pageSize] on Strapi, limit on Supabase. The page size a view declares travels with the request, so the pagination counts the rows the API actually returned. |
order | Record<string, "asc" | "desc"> | — | The sort, field by field. sort[0]=title:asc on Strapi, order=title.asc on Supabase. |
A field filter takes the shape its value has:
- a scalar is an equality —
filters[title][$eq],title=eq.hello; - an array is “any of” —
filters[status][$in],status=in.(draft,published); - an object carries its own operator through untouched.
// Strapi — any operator the REST API accepts
defaultFilter: { title: { $containsi: "hello" } }
// Supabase — any PostgREST operator
defaultFilter: { createdAt: { gte: "2024-01-01" } }An empty value is left out of the request entirely: an empty search box widens the list rather than filtering it down to rows whose field is the empty string.
Errors
Whatever the backend called it — Hydra violations, a Strapi error.details.errors, a PostgREST message — the dialect reads it into one shape, and the form pins each message on the field that caused it. A failure with no field to blame, such as a unique constraint, becomes the toast's description instead. See validation & API errors.
Another API entirely#
A dialect is one object, and ApiDialectInterface is exported to implement it:
import type { ApiDialectInterface } from "react-resource-view"
const myDialect: ApiDialectInterface = {
name: "my-api",
buildRequest: ({ name, path, id, filter, item }) => ({ url: "…", method: "GET" }),
readCollection: (payload) => ({ items: payload.rows, totalItems: payload.count }),
readItem: (payload) => payload.row,
getId: (item) => item?.uuid,
getIdentifier: (item) => item?.uuid,
normalizeError: (payload, status) => ({ status, detail: payload.message }),
referencesAreIris: false,
}buildRequest is called with one of six operations — getCollection, getItem, createItem, updateItem, replaceItem, removeItem — and describes the request rather than sending it; the package's own repository sends it, carrying the base URL, the token and the headers. Two optional members go further: exportRequest lights up the CSV button, and realtimeTopic subscribes a list to a push channel.
A resource that brings its own getCollection, getItem and the rest still bypasses all of this, as it always could — see declaring a resource.