Skip to main content

Build a Custom React App

Goal​

Write a Custom React App — a hand-written React application that runs inside the Parsware Platform Shell — from an empty folder to a package a maker uploads.

A Custom React App is for a screen that tables, forms and views cannot express: a bespoke board, a wizard, an integration console. Everything a model-driven app gets for free (sign-in, the current environment, data access, logging) a Custom React App gets too, through the Runtime SDK.

Prerequisites​

  • Node.js 18 or later and npm.
  • Familiarity with React 18 and TypeScript.
  • A publisher and a solution in the environment the app will be installed into. The publisher's prefix (for example par) starts the app's logical name.

How a Custom React App is hosted​

A Custom React App is not an iframe and not a separate page. It is a Module Federation remote: a bundle the Platform Shell fetches and mounts into its own React tree.

  1. The app shares the Shell's React. react, react-dom and @parsware/runtime-sdk are declared shared singletons, so the app uses the copies the Shell already loaded. Two React copies in one page break every hook.
  2. The app exposes exactly one component. The Shell loads one module, by convention ./App, and renders its default export. It receives no props.
  3. The app reaches the platform through the Runtime SDK. No token, no environment id and no address to configure: useParswareRuntime() hands it all of that already bound.

Steps​

1. Create the project​

The two Parsware packages are downloads on this help site: the Runtime SDK, which the app imports, and the packer, which turns the build into the file a maker uploads. Install them by address, like any npm tarball:

package.json
{
"name": "sales-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"parsware": {
"kind": "CustomApp",
"logicalName": "par_sales_dashboard",
"displayName": "Sales Dashboard",
"description": "Tables and their record counts.",
"requestedScopes": ["modelapps.read"]
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"pack": "node node_modules/@parsware/pack/src/cli.mjs"
},
"dependencies": {
"@parsware/runtime-sdk": "https://docs.parsware.com/sdk/parsware-runtime-sdk-1.0.0.tgz",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@module-federation/vite": "^1.20.3",
"@parsware/pack": "https://docs.parsware.com/sdk/parsware-pack-1.0.0.tgz",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^7.0.2",
"vite": "^8.2.0"
}
}
  • version is the app's own version. The Admin Control Center shows it beside the installed app, so raise it before each upload.
  • parsware says what the build cannot: the logical name the app installs under, the name people see in the Shell, and the scopes it asks for (see step 4).
  • pack calls the packer's file rather than its parsware-pack command, because running a command is an exec, and macOS can refuse to exec a file that came out of a download.

Next to it, a tsconfig.json, an index.html that Vite builds from, and the type declarations for imported files:

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
index.html
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>Sales Dashboard</title></head>
<body></body>
</html>
src/vite-env.d.ts
/// <reference types="vite/client" />

The index.html is only there because Vite needs an entry to build from. The Shell never loads it.

Then npm install.

2. Configure Module Federation​

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

export default defineConfig({
plugins: [
react(),
federation({
name: 'par_sales_dashboard', // the container's name: unique per environment
filename: 'remoteEntry.js', // what the Shell fetches
exposes: { './App': './src/App.tsx' },
shared: {
react: { singleton: true, requiredVersion: '^18.3.1' },
'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
'@parsware/runtime-sdk': { singleton: true },
},
manifest: true, // writes dist/mf-manifest.json, which the packer reads
}),
],
// Module Federation's shared-scope negotiation needs modern output.
build: { target: 'esnext' },
});

manifest: true is not optional. It is off by default, and without it the packer stops with No mf-manifest.json: the container's name, the exposed module and the entry file are read from that file, so they are never typed twice and cannot drift from this config.

Name the container after the app's logical name. Two apps with one name overwrite each other in the browser.

3. Write the app component​

The exposed module's default export is what the Shell mounts:

src/App.tsx
import { useEffect, useState } from 'react';
import { useParswareRuntime, type EntityDto } from '@parsware/runtime-sdk';

interface Row { entity: EntityDto; count: number }

export default function App() {
// 1. The runtime for the environment the Shell opened this app in.
const runtime = useParswareRuntime();
const [rows, setRows] = useState<Row[] | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;

// 2. Read through the SDK: no token, no environment id, no address.
(async () => {
try {
const entities = await runtime.data.listEntities();
const counted = await Promise.all(entities.map(async (entity) => {
const page = await runtime.data.queryRecords(entity.logicalName, { pageSize: 1 });
return { entity, count: page.totalCount };
}));
if (!cancelled) setRows(counted);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
runtime.log('error', 'Sales dashboard failed to load', { message });
if (!cancelled) setError(message);
}
})();

return () => { cancelled = true; }; // 3. Never set state after the app is closed.
}, [runtime]);

if (error) return <p role="alert">Could not load data: {error}</p>;
if (rows === null) return <p>Loading…</p>;

return (
<section>
<h2>Sales Dashboard</h2>
<p>{runtime.user.name} in {runtime.environmentName}</p>
<table>
<tbody>
{rows.map(({ entity, count }) => (
<tr key={entity.id}><td>{entity.displayCollectionName}</td><td>{count}</td></tr>
))}
</tbody>
</table>
</section>
);
}

Everything the runtime offers — queries, saved queries, Custom APIs, storage and the log — is in Use the Runtime SDK.

4. Ask for the scopes it needs​

requestedScopes names what kind of calls the app makes: modelapps.read to read records, modelapps.write to change them, storage.read and storage.write for files. When somebody opens the app, the Shell intersects that list with what their own session holds and hands the result to the app as runtime.scopes.

Read it to leave out what cannot be used:

const canEdit = runtime.scopes.includes('modelapps.write');
{canEdit && <button onClick={save}>Save</button>}

Every call the app makes is made as the person using it, so their security role decides what each call may read and change, exactly as it does in a model-driven app. An app can never do more than the person who opened it.

5. Pack it​

npm run pack # builds, then writes package/<logicalName>-<version>.zip
npm run pack -- --no-build # package an existing build
npm run pack -- --module ./App # when the remote exposes more than one module

The packer prints what it read and where it wrote the file:

Packaged par_sales_dashboard 1.0.0
scope par_sales_dashboard
module ./App
entry remoteEntry.js
files 18

Upload it in the Admin Control Center: open the solution, then Custom Apps → New.

The archive holds the whole build and a parsware.json made from the two sources above. It is the whole registration: a maker uploads it and types nothing (Register a Custom React App). The code is then stored by the platform and served from its own address, so the solution carries the app: exported and imported into Test, it installs a working app with nothing outside to keep running.

The packer refuses what the platform would refuse later, such as a missing parsware block or a logical name with no prefix, so the mistake shows in your terminal rather than as a rejected upload.

6. Test it​

Render the component under a stub runtime with Vitest and Testing Library:

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom

Give the tests their own config. Vitest reads vitest.config.ts instead of vite.config.ts, which keeps Module Federation out of them: its virtual modules are for the browser, not for a unit test.

vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

// globals: Testing Library clears the page between tests only when Vitest's globals are on.
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true } });

Only what the app calls needs to be real in the stub:

src/App.test.tsx
import { expect, it } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import { ParswareRuntimeProvider, type ParswareRuntime } from '@parsware/runtime-sdk';
import App from './App';

const runtime = {
environmentName: 'Development',
user: { id: 'u1', name: 'Dev User', email: 'dev@example.com', tenantId: 't1', roles: [] },
scopes: ['modelapps.read'],
log: () => {},
data: {
listEntities: async () => [{ id: 'e1', logicalName: 'par_order', displayName: 'Order',
displayCollectionName: 'Orders', primaryFieldLogicalName: 'par_name' }],
queryRecords: async () => ({ items: [], totalCount: 42, pageNumber: 1, pageSize: 1,
totalPages: 42, hasNextPage: true, hasPreviousPage: false }),
},
} as unknown as ParswareRuntime;

it('shows each table with its record count', async () => {
render(<ParswareRuntimeProvider runtime={runtime}><App /></ParswareRuntimeProvider>);
expect(await screen.findByText('Orders')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});

Run them with npx vitest run. Cover an empty environment and a rejected call as well as the happy path.

7. Hot reload while you write it​

Packing for every change is a slow loop. In a development environment an app can be registered against your running dev server instead (Hosted mode), which keeps hot reload. There is no screen for it: it is one call, POST /environments/{environment}/solutions/{solution}/custom-apps/hosted, naming the address of your remoteEntry.js. The platform refuses that anywhere else, because an app whose code lives on your machine is an app its solution cannot carry. The Shell must also allow the dev server's address (VITE_REMOTE_ALLOWED_ORIGINS).

What happens behind the scenes​

When somebody opens the app, the Shell asks Identity for a short-lived App Context Token naming the app and the environment, with the app's requested scopes narrowed to the person's own; that narrowed list is runtime.scopes. The Shell then fetches remoteEntry.js from the platform's own address, lets the remote bind to the React and Runtime SDK it already loaded, and renders the exposed component inside a runtime bound to the environment. The SDK's calls go out with the person's own sign-in, so the server checks each one against their security role.

Troubleshooting​

  • No mf-manifest.json when packing — add manifest: true to federation({ … }).
  • Cannot find module … for side-effect import of './x.css' — src/vite-env.d.ts is missing (step 1).
  • Cannot resolve entry module index.html — Vite needs the index.html from step 1, even though the Shell never loads it.
  • "Invalid hook call" as soon as the app opens — two React copies. react and react-dom must be singleton: true in shared.
  • useParswareRuntime() throws — the component rendered outside a runtime provider. In the Shell that cannot happen; in a test, wrap it in ParswareRuntimeProvider as in step 6.
  • A call is refused — the person using the app is not allowed to do that: their security role decides. Check runtime.scopes and leave out what they cannot do.
  • The exposed module cannot be found — the module must match the exposes key exactly, leading ./ included.
  • Use the Runtime SDK.
  • Build a Custom Control.
  • Register a Custom React App.