Connecting to a custom backend#
Palmyra’s frontend does not require a Palmyra backend. Every grid, form, lookup, chart, and tree component talks through interfaces, not concrete HTTP classes. If your API speaks a different shape — REST with a different envelope, GraphQL, gRPC-web, Firebase, Supabase, a legacy SOAP service — you can plug it in by implementing the same interfaces.
This page walks through the seam: the contracts you implement, the factory you register, and what changes (and what stays the same) on the React side.
The interfaces#
All contracts live in @palmyralabs/palmyra-wire · lib/palmyra/store/AsyncStore.ts.
| Interface | Purpose | When it’s called |
|---|---|---|
QueryStore<T> |
Paged list reads + single-record get + schema | SummaryGrid, PalmyraGrid, useServerQuery |
GridStore<T> |
QueryStore + export() |
Grid export button |
DataStore<T> |
QueryStore + post / put / save / remove |
PalmyraNewForm, PalmyraEditForm, DialogNewForm, DialogEditForm |
LookupStore<T> |
Query-only (autocomplete / pickers) | MantineServerLookup, MuiServerLookup |
ChartStore<T> |
One-shot array fetch | Chart widgets |
TreeQueryStore<T, R> |
getRoot / getChildren |
AsyncTreeMenu, AsyncTreeMenuEditor |
AuthDecorator |
Mutate outgoing requests (add tokens, headers) | Every store, before each call |
You don’t have to implement all of them — implement only the ones your app uses.
Signatures at a glance#
interface QueryStore<T> extends AbstractQueryStore<T> {
queryLayout(request: QueryRequest): Promise<any>;
get(request: GetRequest): Promise<T>;
getIdentity(o: T): any;
getIdProperty(): strings;
}
interface GridStore<T> extends QueryStore<T> {
export(request: ExportRequest): void;
}
interface DataStore<T> extends QueryStore<T> {
post(data: T, request?: PostRequest): Promise<T>;
put(data: T, request?: PutRequest): Promise<T>;
save(data: T, request?: PutRequest): Promise<T>;
remove(key: T | any, request?: RemoveRequest): Promise<T>;
}
interface LookupStore<T> extends AbstractQueryStore<T> { }
interface ChartStore<T> {
query(request: QueryRequest): Promise<T[]>;
}
interface TreeQueryStore<T, R> extends BaseQueryStore<T> {
getChildren(data: T, options?: AbstractHandler): Promise<QueryResponse<R>>;
getRoot(options?: AbstractHandler): Promise<R>;
}
interface AuthDecorator {
decorate(request: any): void;
}The shared base:
interface AbstractQueryStore<T> extends BaseQueryStore<T> {
query(request: QueryRequest): Promise<QueryResponse<T>>;
}
interface BaseQueryStore<T> {
getClient(): AxiosInstance; // return your HTTP client — or a stub if you don't use axios
}QueryRequest, QueryResponse<T>, GetRequest, PostRequest, etc. are defined in Types.ts and documented on the AsyncStore contracts page.
The StoreFactory interface#
The React context that drives every grid / form / lookup component expects a factory — not individual stores. The factory interface is defined in @palmyralabs/palmyra-wire · lib/palmyra/store/Types.ts:
interface StoreFactory<T, O extends StoreOptions>
extends FormStoreFactory<T, O>,
GridStoreFactory<T, O>,
ChartStoreFactory<T, O>,
TreeStoreFactory<T, O> { }Broken into its four parents:
interface FormStoreFactory<T, O extends StoreOptions> {
getFormStore(options: O, endPoint: IEndPoint, idProperty?: strings): DataStore<T>;
getLookupStore(options: O, endPoint: IEndPoint, idProperty: strings): LookupStore<T>;
}
interface GridStoreFactory<T, O extends StoreOptions> {
getGridStore(options: O, endPoint: IEndPoint, idProperty?: strings): GridStore<T>;
}
interface ChartStoreFactory<T, O extends StoreOptions> {
getChartStore(options: O, endPoint?: IEndPoint): ChartStore<T>;
}
interface TreeStoreFactory<T, O extends StoreOptions> {
getTreeStore(options: O, endPoint: IEndPoint): TreeQueryStore<any, any>;
}The generic O extends StoreOptions:
interface StoreOptions {
endPointOptions?: Record<string, string | number>;
axiosCustomizer?: (axios: AxiosInstance) => void;
}You may define your own options type that extends StoreOptions to carry backend-specific configuration (auth tokens, tenant ids, custom headers).
Step-by-step: implement a custom store factory#
1. Implement the stores your app needs#
Example — a backend that returns { items: [...], count: N } instead of Palmyra’s { result: [...], total: N }:
// src/wire/MyGridStore.ts
import type { GridStore, QueryRequest, QueryResponse, GetRequest, ExportRequest }
from '@palmyralabs/palmyra-wire';
import axios, { type AxiosInstance } from 'axios';
export class MyGridStore<T> implements GridStore<T> {
private client: AxiosInstance;
constructor(private baseUrl: string, private endPoint: string) {
this.client = axios.create({ baseURL: baseUrl });
}
getClient() { return this.client; }
async query(request: QueryRequest): Promise<QueryResponse<T>> {
const params: any = { ...request.filter };
if (request.limit) params.per_page = request.limit;
if (request.offset) params.page = Math.floor(request.offset / (request.limit || 15)) + 1;
if (request.sortOrder) {
const [field, dir] = Object.entries(request.sortOrder)[0] ?? [];
if (field) params.sort = `${dir === 'desc' ? '-' : ''}${field}`;
}
const { data } = await this.client.get(this.endPoint, { params });
// Map YOUR backend's envelope to Palmyra's QueryResponse
return {
result: data.items, // your backend says "items"
total: data.count, // your backend says "count"
limit: request.limit,
offset: request.offset,
};
}
async queryLayout(_request: QueryRequest) { return {}; }
async get(request: GetRequest): Promise<T> {
const { data } = await this.client.get(`${this.endPoint}/${request.key}`);
return data;
}
getIdentity(o: any) { return o.id; }
getIdProperty() { return 'id'; }
export(request: ExportRequest) {
window.open(`${this.baseUrl}${this.endPoint}/export?format=${request.format}`);
}
}Do the same for DataStore (add post / put / save / remove) and LookupStore (just query) as needed.
2. Implement the factory#
// src/wire/MyStoreFactory.ts
import type { StoreFactory, StoreOptions, IEndPoint, strings,
GridStore, DataStore, LookupStore, ChartStore, TreeQueryStore }
from '@palmyralabs/palmyra-wire';
import { MyGridStore } from './MyGridStore';
import { MyDataStore } from './MyDataStore';
import { MyLookupStore } from './MyLookupStore';
export class MyStoreFactory implements StoreFactory<any, StoreOptions> {
constructor(private baseUrl: string) {}
getGridStore(options: StoreOptions, endPoint: IEndPoint, idProperty?: strings): GridStore<any> {
return new MyGridStore(this.baseUrl, endPoint as string);
}
getFormStore(options: StoreOptions, endPoint: IEndPoint, idProperty?: strings): DataStore<any> {
return new MyDataStore(this.baseUrl, endPoint as string);
}
getLookupStore(options: StoreOptions, endPoint: IEndPoint, idProperty: strings): LookupStore<any> {
return new MyLookupStore(this.baseUrl, endPoint as string);
}
getChartStore(options: StoreOptions, endPoint?: IEndPoint): ChartStore<any> {
throw new Error('Charts not implemented');
}
getTreeStore(options: StoreOptions, endPoint: IEndPoint): TreeQueryStore<any, any> {
throw new Error('Tree not implemented');
}
}3. Register in the React context#
Swap PalmyraStoreFactory for your factory — everything else stays the same:
// src/main.tsx
import { StoreFactoryContext } from '@palmyralabs/palmyra-wire';
import { MyStoreFactory } from './wire/MyStoreFactory';
const factory = new MyStoreFactory('https://api.yourcompany.com');
createRoot(document.getElementById('root')!).render(
<MantineProvider>
<StoreFactoryContext.Provider value={factory}>
<App />
</StoreFactoryContext.Provider>
</MantineProvider>
);4. No page code changes#
Every SummaryGrid, PalmyraGrid, DialogNewForm, DialogEditForm, MantineServerLookup, and PalmyraViewForm in your app resolves its store from the context. Swapping the factory is the only change — no page component touches the wire layer directly.
// This component works identically whether the factory behind it
// is PalmyraStoreFactory, MyStoreFactory, or a test mock.
<SummaryGrid
columns={employeeColumns}
options={{ endPoint: '/employees' }}
pageSize={[15, 30, 45]}
gridRef={gridRef}
/>What you map, what you skip#
| Factory method | Implement if you use… | Skip if you don’t use… |
|---|---|---|
getGridStore |
SummaryGrid, PalmyraGrid, StaticGrid (server-fetched) |
Static-only grids fed by rowData |
getFormStore |
PalmyraNewForm, PalmyraEditForm, DialogNewForm, DialogEditForm |
No create / edit flows |
getLookupStore |
MantineServerLookup, MuiServerLookup |
No server-backed dropdowns |
getChartStore |
Chart widgets | No dashboards |
getTreeStore |
AsyncTreeMenu, AsyncTreeMenuEditor |
No hierarchical menus |
Throw Error('not implemented') for the rest — the app will tell you at runtime if a component tries to use a factory method you haven’t wired.
Common integration patterns#
REST API with a different envelope#
Map query() results into QueryResponse<T> (as shown above). The grid only cares about { result, total, limit, offset }.
GraphQL backend#
Inside query(), build a GraphQL query string from QueryRequest.filter / sortOrder / fields, POST it to your /graphql endpoint, and map the response into QueryResponse<T>. The grid doesn’t know it’s GraphQL.
Firebase / Firestore#
Use the Firebase SDK inside each store method. query() calls getDocs(collection(db, endPoint)) with filters, maps the snapshot into QueryResponse<T>. post() calls addDoc(...).
Legacy SOAP / XML service#
Parse XML in the store methods, return JSON objects. The React components never see XML.
Mock store for testing#
Implement the factory with hard-coded arrays — useful for Storybook, unit tests, and design-time previews without a running backend:
class MockGridStore<T> implements GridStore<T> {
constructor(private data: T[]) {}
getClient() { return axios.create(); }
async query(req: QueryRequest) {
return { result: this.data, total: this.data.length, limit: 15, offset: 0 };
}
async get(req: GetRequest) { return this.data[0]; }
// ...
}See also#
- AsyncStore contracts — full interface signatures.
- PalmyraStoreFactory — the default implementation for Palmyra backends.
- PalmyraAbstractStore — if you want to extend the default HTTP base rather than start from scratch.