packages/twenty-codex-plugin/references/develop-app/standalone-pages.md
Use this reference when a Twenty app needs a full-page custom UI: an operational console, map, canvas, planner, status wall, or other page-sized tool.
For general page layout and navigation entities, use layout.md. For front component source, runtime imports, hooks, data access, and browser verification, use front-components.md. For visual polish and Twenty UI component choices, use ../design/front-component-ui.md. For CLI and deployment command details, use ../manage-app/cli-and-sync.md.
This reference owns only the standalone-page assembly pattern and full-page behavior. It repeats small code fragments where they are needed to show how the pieces connect, but leaves general API behavior to the linked references.
A standalone page is not a raw page body component. In the current local app pattern, custom page content should be rendered through a FRONT_COMPONENT widget inside a STANDALONE_PAGE page layout. There does not appear to be a separate public "page body component" API for app-defined standalone pages.
The current model is:
defineFrontComponent.definePageLayout with type: 'STANDALONE_PAGE'.defineNavigationMenuItem using NavigationMenuItemType.PAGE_LAYOUT./page/:pageLayoutId route and renders the front component widget inside the page layout.Use these surfaces for different jobs:
| Surface | Use it for | Primary app entity |
|---|---|---|
| Standalone page | A full workspace page that is not tied to one record | STANDALONE_PAGE + PAGE_LAYOUT navigation item + FRONT_COMPONENT widget |
| Record page layout | Tabs and widgets for one object record | RECORD_PAGE page layout or page layout tab |
| Dashboard | Metric and report composition from built-in widgets | DASHBOARD page layout |
| Command or side panel | Short actions, focused forms, one selected record, or background commands | defineFrontComponent plus command menu item |
Every freshly scaffolded app contains three placeholder files that wire a "Welcome" sidebar item to a generic landing page:
src/front-components/main-page.tsxsrc/page-layouts/main-page.page-layout.tssrc/navigation-menu-items/main-page.navigation-menu-item.tsIf the app has no user-facing page — for example, it only extends standard objects, declares logic functions, or seeds workflows — delete all three files before the first deploy. Leaving them in ships a dead sidebar item.
Use this minimal file set:
src/constants/universal-identifiers.tssrc/front-components/<name>.front-component.tsxsrc/page-layouts/<name>.page-layout.tssrc/navigation-menu-items/<name>.navigation-menu-item.tsStart with a tiny component that proves the route works before building the full page.
export const MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a0a1c4f0-f23a-4c59-93c5-92146d64b110';
export const MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'63d4970d-6c54-49c8-9c15-52d7cb00fb7a';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
const MissionControl = () => {
return (
<main
style={{
boxSizing: 'border-box',
display: 'grid',
minHeight: '100%',
padding: 24,
placeItems: 'center',
width: '100%',
}}
>
<h1 style={{ margin: 0 }}>Mission Control</h1>
</main>
);
};
export default defineFrontComponent({
universalIdentifier: MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'mission-control',
description: 'Standalone mission control page.',
component: MissionControl,
});
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
import {
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
export default definePageLayout({
universalIdentifier: MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'Mission Control',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: 'e6963ad3-e5fa-41c7-83e1-4fc2ca5de9a8',
title: 'Mission Control',
position: 0,
icon: 'IconRocket',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: '18ce05bb-ee3c-4332-80a7-f8fb84f7f70a',
title: 'Mission Control',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
MISSION_CONTROL_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import {
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
} from '../constants/universal-identifiers';
export default defineNavigationMenuItem({
universalIdentifier: '61d44a63-16e8-4fbe-bccb-9c220d44fdb9',
name: 'Mission Control',
icon: 'IconRocket',
color: 'blue',
position: 50,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier:
MISSION_CONTROL_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
});
Use PageLayoutTabLayoutMode.CANVAS for the full-page renderer. Keep the 12 x 12 fill pattern as a grid fallback and editing hint; CANVAS renders the first widget as the page-sized surface.
After the tiny page renders, replace the component body with a full-screen structure:
const MissionControl = () => {
return (
<main
style={{
boxSizing: 'border-box',
display: 'grid',
gap: 16,
gridTemplateRows: 'auto minmax(0, 1fr)',
height: '100%',
minHeight: '100%',
overflow: 'hidden',
padding: 16,
width: '100%',
}}
>
<header style={{ display: 'flex', justifyContent: 'space-between' }}>
<h1 style={{ margin: 0 }}>Mission Control</h1>
<button type="button">Refresh</button>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: '280px minmax(0, 1fr)',
minHeight: 0,
overflow: 'hidden',
}}
>
<aside style={{ minHeight: 0, overflow: 'auto' }}>Filters</aside>
<div style={{ minHeight: 0, overflow: 'auto' }}>Workspace data</div>
</section>
</main>
);
};
This section only calls out the fields that matter for standalone pages. Use layout.md and front-components.md for broader entity guidance.
definePageLayout owns the standalone route target:
type: 'STANDALONE_PAGE'.objectUniversalIdentifier; standalone pages are not record scoped.PageLayoutTabLayoutMode.CANVAS; put the FRONT_COMPONENT first and keep a 12 x 12 gridPosition as fallback/editing intent.FRONT_COMPONENT widget with configurationType: 'FRONT_COMPONENT' and frontComponentUniversalIdentifier.defineFrontComponent owns the actual page experience:
component, name, description, and a stable universalIdentifier.twenty-sdk/front-component for runtime hooks, host navigation, snackbars, application variables, and side panel actions.twenty-client-sdk/core for workspace records when the page is data driven.getPublicAssetUrl from twenty-sdk/define for app-bundled images or static files.defineNavigationMenuItem owns sidebar reachability:
type: NavigationMenuItemType.PAGE_LAYOUT.pageLayoutUniversalIdentifier to the standalone page layout universal identifier.name, icon, color, and position.folderUniversalIdentifier only when the page belongs under an existing app folder.Public assets and non-secret application variables make standalone pages richer without hardcoding environment data:
import { defineFrontComponent, getPublicAssetUrl } from 'twenty-sdk/define';
import { getApplicationVariable } from 'twenty-sdk/front-component';
const logoUrl = getPublicAssetUrl('mission-control-logo.png');
const MissionBrand = () => {
const label = getApplicationVariable('MISSION_LABEL') ?? 'Mission Control';
return ;
};
The front component only fills the page if every layer inside the widget has deterministic sizing. Start the root at height: '100%', minHeight: '100%', width: '100%', and boxSizing: 'border-box'.
Use minmax(0, 1fr) and minHeight: 0 for scrollable grid or flex children. Without these constraints, inner tables, maps, and canvases can force the page taller than the widget or collapse into a zero-height area.
Prefer one immersive tool surface over a dashboard made of many small cards. If the page is a mission tracker, map, editor, kanban, planner, or cockpit, make the front component own the composition and use internal panels only where they support the workflow.
Assume the available area changes with Twenty chrome, the left sidebar, side panel state, tab list, and smaller screens. Use responsive CSS inside the front component:
minHeight: 0.Avoid hidden overflow traps. overflow: 'hidden' is useful for map and canvas roots, but pair it with explicit scroll containers for lists and diagnostics.
Fetch live workspace records from the front component when the page reflects workspace state:
Use front-components.md for general client/runtime rules. In standalone pages, the key additions are visible full-page loading, empty, and error states, plus navigation from the standalone surface back into Twenty records.
import { useEffect, useState } from 'react';
import { CoreApiClient, type CoreSchema } from 'twenty-client-sdk/core';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
AppPath,
enqueueSnackbar,
navigate,
} from 'twenty-sdk/front-component';
type CompanySummary = Pick<CoreSchema.Company, 'id' | 'name'>;
const CompaniesStandalonePage = () => {
const [companies, setCompanies] = useState<CompanySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const loadCompanies = async () => {
try {
setLoading(true);
setError(null);
const client = new CoreApiClient();
const result = await client.query({
companies: {
edges: {
node: {
id: true,
name: true,
},
},
},
});
if (!cancelled) {
setCompanies(result.companies.edges.map((edge) => edge.node));
}
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to load companies';
if (!cancelled) {
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadCompanies();
return () => {
cancelled = true;
};
}, []);
if (loading) return <div style={{ padding: 24 }}>Loading companies...</div>;
if (error) return <div style={{ padding: 24 }}>{error}</div>;
if (companies.length === 0) {
return <div style={{ padding: 24 }}>No companies yet.</div>;
}
return (
<div style={{ display: 'grid', gap: 8, padding: 24 }}>
{companies.map((company) => (
<button
key={company.id}
type="button"
onClick={() =>
navigate(AppPath.RecordShowPage, {
objectNameSingular: 'company',
objectRecordId: company.id,
})
}
>
{company.name}
</button>
))}
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '5aa8c51f-72a4-4929-a6ab-2d4ea9d2a6df',
name: 'companies-standalone-page',
description: 'Lists companies and opens related records.',
component: CompaniesStandalonePage,
});
Always model these states:
null.Use navigate for full-page routes and openSidePanelPage for focused side-panel workflows. Prefer navigating to the record route when the user is leaving the standalone experience to inspect a real record.
For a black screen, check the simplest causes first:
CoreApiClient generation, and show an error state.getPublicAssetUrl(...) output in the network tab and render without the asset.zIndex, and full-screen backgrounds until text is visible.height: '100%', minHeight: '100%', minHeight: 0, and the 12 x 12 widget grid position.Console checks:
console.info('Standalone page mounted');
console.info('Companies loaded', companies.length);
Installed-app checks:
/page/:pageLayoutId.Known-good component test:
const KnownGoodStandalonePage = () => (
<main style={{ minHeight: '100%', padding: 24 }}>
<h1>Standalone page runtime is working</h1>
</main>
);
If this renders but the full page does not, the issue is inside the full component. If this does not render, inspect the layout, navigation, sync, installation, and front component registration.
Use local dev sync while iterating and one-shot sync for bounded verification. Use ../manage-app/cli-and-sync.md for exact command behavior, remote setup, verbose troubleshooting, deploys, and logs.
yarn twenty dev --once
Install and update flow:
package.json before publishing an update to a workspace that already has the app installed.Versioning expectations:
package.json version than the installed version.Acceptance checks:
Minimal standalone page:
STANDALONE_PAGE page layout.PAGE_LAYOUT navigation item.FRONT_COMPONENT widget.Full-screen operational page:
minmax(0, 1fr) body.Data-driven page that opens related records:
CoreApiClient.navigate(AppPath.RecordShowPage, { objectNameSingular, objectRecordId }) for record drill-in.Immersive canvas or map-style page, such as a Space X Mission Tracking page: