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.
- The app shares the Shell's React.
react,react-domand@parsware/runtime-sdkare declared shared singletons, so the app uses the copies the Shell already loaded. Two React copies in one page break every hook. - The app exposes exactly one component. The Shell loads one module, by convention
./App, and renders its default export. It receives no props. - 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:
{
"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"
}
}
versionis the app's own version. The Admin Control Center shows it beside the installed app, so raise it before each upload.parswaresays 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).packcalls the packer's file rather than itsparsware-packcommand, 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:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>Sales Dashboard</title></head>
<body></body>
</html>
/// <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
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:
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.
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:
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.jsonwhen packing — addmanifest: truetofederation({ … }).Cannot find module … for side-effect import of './x.css'—src/vite-env.d.tsis missing (step 1).Cannot resolve entry module index.html— Vite needs theindex.htmlfrom step 1, even though the Shell never loads it.- "Invalid hook call" as soon as the app opens — two React copies.
reactandreact-dommust besingleton: trueinshared. useParswareRuntime()throws — the component rendered outside a runtime provider. In the Shell that cannot happen; in a test, wrap it inParswareRuntimeProvideras in step 6.- A call is refused — the person using the app is not allowed to do that: their security
role decides. Check
runtime.scopesand leave out what they cannot do. - The exposed module cannot be found — the module must match the
exposeskey exactly, leading./included.
Related scenarios
- Use the Runtime SDK.
- Build a Custom Control.
- Register a Custom React App.