The Phoenix frontend is built on Remix v2 with React, using standard React components, TypeScript, and Tailwind CSS. Server-side rendering provides performance and SEO benefits out of the box.
Standard React components with Remix conventions for routing, loaders, and server rendering.
Full type safety across the frontend codebase with auto-generated API types from the backend.
Utility-first CSS with theme editor integration for dynamic, customizable styling.
Phoenix uses a filename-based routing system. Any file that ends with .route.tsx will be accessible as a page, with the URL path derived from the file name.
.route.tsx suffix. characters with /_
The _site segment groups routes that share a common layout or upstream data, but is removed from the resulting URL path.
Route files can function as layouts for nested routes by including an <Outlet /> component. Nested pages render inside their parent layout at the Outlet position.
_site — storefront / user-facing pagesadmin — admin pages (no underscore prefix)// _sample.route.tsx (layout)
export default function MyLayout() {
return (
<div>
<h3>This is my layout</h3>
<Outlet />
</div>
);
}
// _sample.nested.route.tsx (nested page)
export default function Page() {
return <p>Hello!</p>;
}
Accessing /nested renders the _sample layout first, then renders the nested page contents in place of the <Outlet />.
Loaders are server-side data loading functions executed before a page renders. They return data used to server-render the page and deliver a pre-populated HTML response to the end user.
export async function loader(args: LoaderFunctionArgs) {
const api = await getApi(args.request);
const myData = await api.GetSomeData();
return json(myData);
}
export async function loader(args: LoaderFunctionArgs) {
const api = await getApi(args.request);
const hasRole = await api.CheckRole("Store Admin");
if (!hasRole) {
return redirect("/login");
}
return json(myData);
}
Convention
Put loaders at the top of your route files, and define them as async function loader(...) instead of const loader = async (...).
For API requests from React components (rather than loaders), use the useApi() hook. This returns an API object similar to the legacy cvApi from CEF.
export const MyComponent = () => {
const api = useApi();
useEffect(() => {
api.GetSomeData().then(r => setSomeState(r));
}, []);
};
API route definitions, auto-generated from backend endpoints.
Input/output DTO types, auto-generated from backend models.
| Scenario | Use |
|---|---|
| Static / public data | Loader |
| SEO-critical content | Loader |
| User-specific data (carts, profile) | useApi |
| Data mutations (create, update, delete) | useApi |
Actions are Deprecated
Remix Actions are painful to work with and often cause bloat or confusion. DO NOT create any new Actions. Use useApi for all data mutations instead.
The CV Grid is a data grid component for paginated data, supporting both client-side and server-side data sources. It requires the data array, a total count, a unique row key, and column definitions.
| Prop | Description |
|---|---|
| source | Array of items to display in the grid |
| totalCount | Total count of the full result set (for pagination) |
| rowKey | Function returning a unique key per row (e.g. database ID) |
| columns | Array of column definitions with title, content renderer, and optional className |
<CVGrid
source={myArrayOfItems}
totalCount={myArrayOfItems.length}
rowKey={i => i.Id}
columns={[
{ title: "Id", content: x => x.Id },
{ title: "Name", content: x =>
<p>{x.FirstName} {x.LastName}</p> },
{ title: "Details", className: "w-0",
content: x => <Button>Click Here</Button> }
]}
/>
Specify filters on a CV Grid by providing an array of filter definitions. Supported filter types:
number Exact match
text String matching
select Dropdown list
numberrange Min/max numeric
daterange Min/max date
<CVGrid
...
filters={[
{ title: "ID", type: "number", key: "Id" },
{ title: "Date", type: "daterange", key: "CreatedDate" }
]}
/>
Columns support three class properties, merged via the cn() utility:
| Property | Applies to | Priority |
|---|---|---|
| className | Both th and td |
Overrides grid defaults |
| thClassName | Header (th) only |
Overrides className |
| tdClassName | Cell (td) only |
Overrides className |
Extension points let submodules open themselves to customization from other submodules or the client project, without being directly edited or overridden.
import ExtensionPoint from "@core/components/PipelineComponents/ExtensionPoint";
export default function MyComponent() {
return (
<div>
<h1>My Component</h1>
<ExtensionPoint name="mycomponent.content" />
</div>
);
}
Create a .ext.tsx file and call registerExtension(). All .ext.tsx files are automatically loaded via glob pattern in root.tsx.
import { registerExtension } from
"@core/components/PipelineComponents/ExtensionProvider";
const MyWidget = () => {
return <div>Hello from my widget!</div>;
};
registerExtension("mycomponent.content", MyWidget, "MyWidget");
Control rendering order by specifying a sort order as the fifth argument to registerExtension(). Lower values render first.
| Constant | Value | Use |
|---|---|---|
| FIRST | 0 | Critical system-level extensions |
| VERY_HIGH | 100 | Important system features |
| HIGH | 300 | Important plugin features |
| NORMAL | 1000 | Default (most extensions) |
| LOW | 1500 | Less important content |
| VERY_LOW | 2000 | Supplemental content |
| LAST | 9999 | Debug / admin tools |
import { registerExtension, EXTENSION_SORT_ORDER }
from "@core/components/PipelineComponents/ExtensionProvider";
// Using constants (recommended)
registerExtension(
"admin.dashboard", HighPriorityWidget,
"HighPriority", false, EXTENSION_SORT_ORDER.HIGH
);
// Using direct numbers (fine-grained control)
registerExtension(
"admin.dashboard", CustomWidget,
"Custom", false, 250
);
// Default order (1000) — omit sort order
registerExtension(
"admin.dashboard", NormalWidget, "Normal"
);
Extension points can pass arbitrary props to their registered extensions:
// In the host component
<ExtensionPoint
name="dashboard.products.$id.Form"
form={form}
/>
// In the extension
interface DashboardProductIdFormProps {
form: UseFormReturn<any, any, undefined>;
}
export const ConditionSelector =
({ form }: DashboardProductIdFormProps) => {
// ... use the form object ...
};
registerExtension(
"dashboard.products.$id.Form",
ConditionSelector, "ConditionSelector"
);
Phoenix supports overriding files from plugins. To override a plugin file, create the same file under /overrides/ instead of /plugins/. The compiler handles the replacement automatically.
Restart Required
After creating an override file, restart your dev server for the change to take effect.
DO NOT Import Override Files Directly
Always import the original file path. The compiler handles the override replacement. Importing override files directly causes hard-to-diagnose build issues.
Use Sparingly
Excessive overrides complicate upgrades, as they create diverged files that must be reconciled. If a plugin file is difficult to customize without overriding, that may indicate the plugin itself needs improvement.
Phoenix uses Tailwind CSS (instead of Bootstrap). Tailwind provides utility-first CSS with powerful features like exact values (p-[4px]) and responsive prefixes.
Apply classes conditionally at specific breakpoints using prefixes. For example, md:px-4 is equivalent to Bootstrap's px-md-4.
The theme editor sets colors as Tailwind variables, available as standard color utilities:
Colors range from -50 (lightest) to -950 (darkest) in increments of 100, matching Tailwind's default color variable structure.
| Class | Purpose |
|---|---|
| rounded-base | Component corner radius from theme editor |
| rounded-container | Container corner radius from theme editor |
| border | Border thickness from theme editor |
cn() FunctionUse the cn() function to merge Tailwind classes cleanly. Classes are combined left to right, with later values overriding earlier ones.
const MyComponent = ({ className }: MyProps) => {
return (
<div className={cn("p-4 my-base-classes", className)}>
Hello!
</div>
);
};
<div className={cn("border", someCondition && "bg-danger-500")}>
DO NOT Interpolate Class Names
Tailwind analyzes code statically to determine which CSS classes to include. Interpolated strings like p-${padding} will not be recognized. Instead, accept a className prop and merge with cn().
The theme editor provides a visual interface for customizing site appearance. Styles are defined as Tailwind variables and applied through a set of critical files.
Default styles configuration
Hook that returns theme classes for an element
CSS property definitions on the backend
Safelisting for dynamic Tailwind classes
Follow these four steps to add theme editor support for a new element:
Edit appSettings.theme.json
Add default styles for your element in the appropriate section. Create the section if it does not exist.
Edit tailwind.constants.ts
Add your section and element name to the safelist array. This ensures the generated classes are included in the build.
Use useElementTheme() in your component
Call const themeClasses = useElementTheme("YourElementName") to get the theme classes.
Apply with cn()
Use cn("default-classes", themeClasses, parentClassNames) so theme values can be overridden when needed.
const MiniMenuButton = ({ className }: Props) => {
const themeClasses = useElementTheme("MiniMenuButton");
return (
<button
className={cn(
"px-3 py-2 rounded-base border",
themeClasses,
className
)}
>
...
</button>
);
};
Follow these five steps to add a new CSS property (e.g. maxHeight) to the theme editor:
Add property in EssentialStyling.cs
public EssentialStyling MaxHeight { get; set; } = new() { EditorType = "slider", Unit = "px" }
Extend tailwind.config.ts
Add to the extend object: maxHeight: extendFromComponents(getComponentCss, "MaxHeight")
Add prefix to tailwind.constants.ts
Add a new key to TailwindPrefix (e.g. "max-h") and to standardPrefixesToPascalCSSProperty.
Update makeSafeList in tailwind.helpers.ts
Add a new item to the result array matching your prefix.
Update useElementTheme.ts (if needed)
Some properties need special handling. For example, Tailwind cannot distinguish text- between font-size and color, requiring text-[length:var(--)] syntax.
Naming Convention
Be specific with element names. Use MiniMenuButton or MiniMenuIcon, not a generic MiniMenu. A "section" can be any container, not just a page.
Extension points are named slots in the Remix UI where plugins can inject custom React components. Each plugin registers its frontend extensions during startup, specifying which extension point to target and what component to render. This allows adding custom UI elements — payment forms, product displays, dashboard widgets — without modifying the core frontend code. See Extension Points.
Yes. The theme editor allows per-site customization of colors, fonts, logos, and layout options. Themes are stored as configuration and applied at runtime through Tailwind CSS custom properties. This enables white-labeling: each client's end-users see a branded experience matching their organization's visual identity. See Theme Editor.
Remix's server-side rendering provides faster initial page loads, better SEO for public-facing pages, and improved performance on low-powered devices. Data loading happens on the server via loaders, reducing client-side JavaScript bundle size. The nested routing system with outlets enables code-splitting by route, so users only download the code needed for the current page. See Routing and Loaders.
The Remix frontend generates embeddable payment components that can render within ERP interfaces like D365 and NetSuite. These components use iframe isolation or Web Component encapsulation to maintain PCI compliance — the ERP host page cannot access the payment form's DOM. Card data flows directly from the component to the payment provider, and only a token is returned to Clarity. See Embedded vs. Integrated Payments.