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.

setup.ts
import { configureApi, strapiDialect } from "react-resource-view"

configureApi({
  baseUrl: "https://cms.example.com",
  getAuthToken: () => (isLogged() ? getUserToken() : undefined),
  dialect: strapiDialect(),
})
NameTypeDefaultDescription
jsonLdDialect()ApiDialectInterfacethe defaultAPI Platform and Hydra. member / totalItems, IRIs, page and itemsPerPage, Hydra violations, Mercure, CSV export.
strapiDialect()ApiDialectInterfaceStrapi v4 and v5. pagination[page], filters[field][$eq], sort[0], populate, writes wrapped in data, documentId.
supabaseDialect()ApiDialectInterfaceSupabase, 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#

setup.ts
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" } } },
  },
})
NameTypeDefaultDescription
apiPathstring"/api"Prefix of the REST routes. A path that already carries it is left alone, so "articles" and "/api/articles" both work.
populatestring | 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.
defaultOperatorstring"$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#

setup.ts
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" } } } },
})
NameTypeDefaultDescription
apiKeystring | (() => 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.
primaryKeystring"id"How a row is addressed — PostgREST has no item route, so a single row is a filter on this column.
selectstring"*"Sent with every read. "*,author(*)" embeds a relation, the way Supabase joins.
schemastringpublicSent 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.
restPathstring"/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" } } },
}
NameTypeDefaultDescription
pagenumber11-based page. pagination[page] on Strapi, an offset on Supabase, page on API Platform.
itemsPerPagenumberview.itemsPerPageRows 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.
orderRecord<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:

myDialect.ts
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.