Writing a Mod Frontend
A frontend mod is an NPM package that delivers the client-side surface of a bounded context: API hooks, GraphQL documents, widgets, optional pages, assets, and extension points.
A well-designed mod package follows the same Open/Closed Principle as the backend:
- The mod is open for extension through
@webinex/flexycomponent replacing or decorating, GraphQL schema extensions and global settings. - The mod is closed for modification in normal host-specific use cases.
- The mod should be usable by a host application without importing host-specific domain code
Package Structure
A mod frontend is published as a single package. The package normally contains:
lib/api- RTK Query endpoints, request and response types, and GraphQL document builders.lib/widgets- reusable UI widgets, forms, tables, mod-specific theme wrappers, global settings, and extension registries.lib/pages- optional page components when a mod screen is a standalone reusable unit.lib/utils- mod-specific helpers that are useful to the host or other widgets.assets- translations, styles, or static assets that must be distributed with the package.lib/index.ts- the public entry point that re-exports API, widgets, pages, and supported utilities.
API Layer
Mod APIs extend the host baseApi instead of creating an independent Redux API slice.
This allows all mod endpoints to share the host store, middleware, cache, tags, and request
infrastructure.
export const tasksApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getTask: builder.query<Task, TaskRequest<{ id: string }>>({
queryFn: rtkq.graphql(() => TaskGraphQuery.TaskQuery, {
select: (x) => x.task,
vars: ({ id }) => ({ id }),
}),
providesTags: (_, __, { id }) => [{ type: 'main:task', id }, 'main:task'],
}),
updateTask: builder.mutation<void, TaskRequest<UpdateTaskRequest>>({
queryFn: rtkq.axios((args) => Http.put(`/api/task/${args.id}`, args)),
invalidatesTags: ['main:task'],
}),
}),
});
Use GraphQL for read models when the response shape benefits from schema composition. Use REST commands for mutations that execute an application action. This mirrors the backend split between GraphQL read surfaces and command-oriented interactors/controllers.
Extensible GraphQL Documents
The host application can extend backend GraphQL types. Because of that, frontend mod queries must also be extendable. A mod owns the base GraphQL selection for its own contract, and the host app adds fields that exist only in the composed host schema.
GraphQL documents are built from fragment body properties or functions:
export const BillingGraphQL = {
ChargeListQuery: () => {
return new Document<{ charges: ChargeListItem[] }, { query?: AskyQuery }>(`
query getChargeListQuery($query: JSON) {
charges(query: $query) {
${BillingGraphQL.ChargeListItemFragmentBody()}
}
}
`);
},
ChargeListItemFragmentBody: () => `
id
productId
price {
amount
currency
}
`,
};
The important part is that the document reads the fragment body at execution time. If the host updates the fragment body before a query hook is used, all widgets that call this query receive the extended response shape.
Host-side extensions
The host extends fragment bodies in an integration module. This module must be imported once during application startup, before any widget executes the affected query.
import { BillingGraphQL } from '@mod/billing';
import { UserLookup } from './usersTypes';
declare module '@mod/billing' {
export interface PaymentListItem {
payer: UserLookup | null;
}
}
BillingGraphQL.PaymentListItemFragmentBody = fn.hof(
BillingGraphQL.PaymentListItemFragmentBody,
(prev) => `${prev()} payer { id firstName lastName email avatar active }`,
);
The TypeScript type extension and the GraphQL selection must be kept together. If the host augments
PaymentListItem with payer, the same integration module should also add the payer { ... }
selection to the fragment body. Type augmentation without a GraphQL field produces runtime
undefined values. A GraphQL field without type augmentation forces widgets to use unsafe casts.
Extension rules
- The mod should include fields that belong to its bounded context.
- The host should add fields that exist because of host-specific schema composition.
- A mod query must not select host-only fields such as
patient,encounter, ormedicalProvider. - Host extension modules should be imported for their side effects at application startup.
- Keep the base fragment valid without host extensions.
Widgets
Widgets are the primary UI delivery unit of a frontend mod. Unlike low-level components, a widget is a plug-and-play feature surface that can be embedded into host pages, dashboards, mod pages, and other widgets.
A widget should accept the minimum set of data props required to define its external context. It should own its data fetching, loading state, empty state, mutation calls, and mod-specific behavior whenever possible. This keeps the widget reusable across multiple host locations without forcing each consumer to duplicate query logic.
A data-driven widget is usually too coupled to its caller:
export interface ClaimListWidgetProps {
items: ClaimListItem[];
columns: ClaimListColumnsSettings;
readonly?: boolean;
}
In this case, every widget consumer must know how to load items and how to build columns. The
result is less reusable because the data-loading logic leaks out of the mod widget.
A plug-and-play widget keeps the host-facing contract smaller:
export interface ClaimListWidgetProps {
patientId?: string;
columns?: (defaultColumns: ClaimListColumnsSettings) => ClaimListColumnsSettings;
readonly?: boolean;
}
Here, the host only provides the contextual filter and optional behavior configuration. The widget fetches the claim list by itself and can be reused on a patient page, a general dashboard, or inside another widget.
Widget design rules:
- Accept context props and configuration props, not preloaded read models, unless the widget is intentionally presentation-only.
- Keep configuration props optional when the widget can provide a safe default.
- Own the query, loading state, empty state, and mutation callbacks that belong to the mod workflow.
- Expose callback props for host reactions, but do not require the host to orchestrate the whole workflow.
- Split large widgets into meaningful internal components that can be overridden through
@webinex/flexy. - Prefer configuration callbacks such as
(defaults) => nextValuewhen the host should extend defaults instead of replacing them.
UI Extension with @webinex/flexy
@webinex/flexy is the primary UI extension mechanism for frontend mods. It lets a mod define
default components while allowing the host to replace or decorate those components inside a React
subtree.
The host can add content before or after a component, wrap it with additional behavior, change the props passed to the original implementation, or replace the implementation completely without modifying the mod package.
export const ClaimTitle = flexy('ClaimTitle', ({ claim }: { claim: Claim }) => (
<span>{claim.number}</span>
));
Add content before the original component:
const THEME: $Flexy<typeof ClaimTitle> = {
ClaimTitle: (props) => (
<>
<ClaimStatusBadge status={props.claim.status} />
<ClaimTitle.Component {...props} />
</>
),
};
Add content after the original component:
const THEME: $Flexy<typeof ClaimTitle> = {
ClaimTitle: (props) => (
<>
<ClaimTitle.Component {...props} />
<ClaimAmount amount={props.claim.amount} />
</>
),
};
Replace the component completely:
const THEME: $Flexy<typeof ClaimTitle> = {
ClaimTitle: (props) => {
return (
<div>
<span>Claim #{props.claim.number}</span>
<ClaimStatusBadge status={props.claim.status} />
<ClaimAmount amount={props.claim.amount} />
</div>
);
},
};
The host provides replacements through Flexy:
export function CoveragesAppTheme({ children }: PropsWithChildren<object>) {
return (
<Flexy value={THEME} mode="merge">
{children}
</Flexy>
);
}
The same approach allows the host to adjust props before calling the original component. For example, the host can add an async filter to a default table column:
const THEME: TaskThemeValue = {
TaskListTable: (props) => {
const patientOptionSource = usePatientOptionSource();
const columns = props.columns.map((column) =>
column.key === 'referenceId'
? { ...column, filterable: { type: 'async-select', optionSource: patientOptionSource } }
: column,
);
return <TaskListTable.Component {...props} columns={columns} />;
},
};
The mod should export types for its flexy keys so the host can type-check replacements:
export type TaskThemeValue = $Flexy<
| typeof TaskListTable
| typeof TaskListItemAvatar
| typeof TasksTablePanel
| typeof TaskRelatedToValue
| typeof FormTaskOriginatorReference
>;
Component design rules
- Split widgets into meaningful parts. For example, a details form can expose title, sections, action groups, reference renderers, and field renderers.
- Fetch data in the widget and pass loaded values into presentation-level flexy components.
- Pass mutation-bound callbacks to flexy components when the host may need pre-processing, post-processing, confirmation, or analytics around a user action.
- Add
flexyonly for components that are likely to be changed by the host. Making every private element flexy increases the public API surface without adding useful extension power. - Add optional props to base components when the host can configure behavior without replacing the whole component.
- Keep forms extensible through settings or extension registries when the host needs additive fields.
- Make host-data renderers flexy. For example, if a task renders an assignee reference, the renderer should be replaceable so the host can show a name, avatar, link, status, or another host-specific representation.
- Avoid generic keys such as
Value,Cell,Title,Header,Row, orItem. Prefer stable domain-specific keys such asTaskAssignee,ClaimListTable, orProductReference.
Global Settings and Host Hooks
Some mods require host-provided behavior that is not just a visual replacement. Examples include current-user context, option sources, reference loaders, file loaders, URL builders, and other application-level integrations.
For these cases, a mod can export a global settings object that the host fills during startup. This keeps the mod independent from the concrete host package while still allowing widgets to access the required behavior.
export interface ModGlobalSettings {
useUserContext: () => { id: string; } | null;
}
The host wires these settings near its theme module:
ModGlobalSettings.useUserContext = useUserContext;
Use global settings for behavior that is required by many widgets or by core mod workflows. Prefer
props for instance-specific configuration. Prefer flexy when the host only needs to change
rendering.
Pages and Routing
Pages are optional. A mod can export pages when a screen is reusable as a whole, but the host still owns routing, navigation, authorization gates, and page placement.
A mod page should compose mod widgets and accept route-derived parameters as props when possible. Host-specific route names, navigation sections, role access maps, and layout shells should stay in the host application.
For embedded product workflows, prefer exporting widgets and let the host page compose them. This keeps the mod independent from the host information architecture.
Assets and Localization
A mod package can ship translations and static assets through assets. Translation keys should be
namespaced by mod domain, such as tasks, billing, coverages, or assessments.
Keep host-specific labels in the host application. If a mod uses neutral names such as reference,
assignee, or payer, the host can render them as patient, employee, provider, or another
application-specific concept through flexy, settings, and localization.
Integration Checklist
When adding a frontend mod to a host application:
- Install or link the mod package and verify that shared runtime packages remain peer dependencies.
- Import the mod package entry point or the required API/widget modules so
baseApi.injectEndpointsis evaluated. - Extend GraphQL fragment bodies only for fields that exist in the host-composed GraphQL schema.
- Add TypeScript module augmentation for every host-specific field selected by a fragment extension.
- Assign required global settings before rendering widgets that depend on them.
- Create a host
@webinex/flexytheme for visual replacements and wrap the application or target subtree with the corresponding theme provider. - Register routes, navigation items, and authorization gates for exported pages or host-composed screens.
Best Practices
Keep the frontend mod independent from the current host domain. If the mod is tasks, use names
such as Task, Assignee, Reference, Originator, and RelatedTo. Do not introduce
Patient, Encounter, or MedicalProvider into the mod unless those concepts are native to that
mod bounded context.
Design extension points explicitly. Use GraphQL fragment extension for host-specific response
fields, TypeScript module augmentation for the extended shape, @webinex/flexy for visual
composition, global settings for host-owned behavior, and form registries for additive form fields.
Keep default widgets useful without host customization whenever the workflow allows it. If a widget cannot work without host behavior, make the missing dependency explicit through settings or props and fail early with a clear contract.
Prefer additive extension over replacement. Extend fragment bodies, decorate components through
.Component, and transform default configuration through callbacks before replacing the whole
query, widget, or component.
Use Flexy with mode="merge" for application themes so multiple mods and nested widgets can
cooperate. Use mode="replace" only when a subtree intentionally ignores parent overrides.
Treat GraphQL fragment body names, global settings keys, form extension keys, and flexy keys as public API. Renaming them is a breaking change for host integration modules.