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#