Frontend Architecture
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.
Frontend Overview
Remix v2 + React
Standard React components with Remix conventions for routing, loaders, and server rendering.
TypeScript
Full type safety across the frontend codebase with auto-generated API types from the backend.
Tailwind CSS
Utility-first CSS with theme editor integration for dynamic, customizable styling.
Routing System
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.
Path Resolution Rules
- Remove the
.route.tsxsuffix - Replace all
.characters with/ - Remove any segment of the file name that begins with an
_
Route Naming Examples
The _site segment groups routes that share a common layout or upstream data, but is removed from the resulting URL path.
Layouts & Outlets
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
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 (...).
Client-side API (useApi)
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));
}, []);
};
Auto-generated Files
API route definitions, auto-generated from backend endpoints.
Input/output DTO types, auto-generated from backend models.
When to Use useApi vs. Loaders
| 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.
CV Grid
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.
Core Props
| 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> }
]}
/>
Filters
Specify filters on a CV Grid by providing an array of filter definitions. Supported filter types:
<CVGrid
...
filters={[
{ title: "ID", type: "number", key: "Id" },
{ title: "Date", type: "daterange", key: "CreatedDate" }
]}
/>
Column Classes
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
Extension points let submodules open themselves to customization from other submodules or the client project, without being directly edited or overridden.
Creating an Extension Point
import ExtensionPoint from "@core/components/PipelineComponents/ExtensionPoint";
export default function MyComponent() {
return (
<div>
<h1>My Component</h1>
<ExtensionPoint name="mycomponent.content" />
</div>
);
}
Registering an Extension
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");
Sort Order Constants
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"
);
Passing Props to Extensions
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"
);
Extension Point Rendering Flow
File Overrides
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.
Override Resolution
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.
Tailwind CSS
Phoenix uses Tailwind CSS (instead of Bootstrap). Tailwind provides utility-first CSS with powerful features like exact values (p-[4px]) and responsive prefixes.
Media Breakpoints
Apply classes conditionally at specific breakpoints using prefixes. For example, md:px-4 is equivalent to Bootstrap's px-md-4.
Theme Editor Values
The theme editor sets colors as Tailwind variables, available as standard color utilities:
Color Scale
Colors range from -50 (lightest) to -950 (darkest) in increments of 100, matching Tailwind's default color variable structure.
Borders & Radii
| Class | Purpose |
|---|---|
| rounded-base | Component corner radius from theme editor |
| rounded-container | Container corner radius from theme editor |
| border | Border thickness from theme editor |
The cn() Function
Use 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().
Theme Editor
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.
Key Files
Default styles configuration
Hook that returns theme classes for an element
CSS property definitions on the backend
Safelisting for dynamic Tailwind classes
Adding Styles for a Themed Element
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>
);
};
Adding a New Style Property
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.