UI Pages
Guide for creating ServiceNow UI Pages using React and the Fluent DSL. UI Pages are custom React-based interfaces for building forms, dashboards, list views, multi-step wizards, and any custom web experience within ServiceNow. They use React 18.2.0, the @servicenow/react-components library, Table API integration, CSS styling, and SPA routing via URLSearchParams.
When to Use
- When the user explicitly asks for a UI Page or custom interface
- When creating React-based user interfaces in ServiceNow
- When building forms, lists, dashboards, or data entry screens
- When implementing single page applications (SPAs) with routing
- When integrating with ServiceNow Table API for CRUD operations
- When applying theming and CSS styling to custom pages
Instructions
- Component Library: Use
@servicenow/react-componentsfor all UI elements. Before writing UI code, read the component documentation viapackage_docs. - Technology Stack: Always use React 18.2.0. Never use vanilla JavaScript, jQuery, or other frameworks. Use TypeScript when writing code that uses React components in .tsx files.
- Component Selection: List specific ServiceNow React components from
@servicenow/react-componentsto be used (e.g.,NowRecordListConnected,Card,Button). Read documentation for each component before use. - Navigation Architecture: If more than two views exist, define URLSearchParams structure (e.g.,
?view=list,?view=details&id=123). - Dirty State Tracking: If ANY forms, edits, or create views exist, implement dirty state using
useRecord().form.isDirtyand warn on navigation withModal. - Field Utilities: Create
src/client/utils/fields.tsfirst withdisplay()andvalue()helpers. - API Calls: Always use
sysparm_display_value=alland includeX-UserToken: window.g_ckheader. - File Size: Keep files under 100 lines. Break into components when exceeding this limit.
- CSS: Import CSS via ESM (
import "./file.css"). CSS Modules are NOT supported. - Build System: Never create webpack, vite, or build configs. The build system handles everything. Build the app at least once before checking diagnostics.
- Record Forms: When building forms to view/edit ServiceNow records, ALWAYS wrap with
RecordProvider-- NEVER use standalone Input components with manual Table API calls for record CRUD. - URL-Based Navigation (DEFAULT): Each logical part/view of the application (list view, detail view, edit view, create view, tabs, etc.) MUST be accessible via URL using URLSearchParams (e.g.,
?view=list,?view=details&id=123,?tab=overview). - Dependencies: After adding dependencies to package.json, install them. Dependencies:
"react": "18.2.0","react-dom": "18.2.0","@servicenow/react-components": "^0.1.0"(the caret^is required), and devDependencies:"@types/react": "18.3.12".
Key Concepts
UiPage API
UI Pages must be created using the UiPage API from @servicenow/sdk/core:
import { UiPage } from "@servicenow/sdk/core";
import page from "../../client/index.html";
export const my_page = UiPage({
$id: Now.ID["my-page"],
endpoint: "x_app_page.do", // CRITICAL: must begin with ${scope_name}_. Scope name here is x_app.
html: page, // CRITICAL: must import the content to use the output of the build system
direct: true // CRITICAL: Must be true
});
Project Structure
src/
client/
tsconfig.json # TypeScript config
index.html # Entry HTML (HTML format, no DOCTYPE or XML preamble)
main.tsx # React bootstrap
app.tsx # Main component written in TypeScript
utils/fields.ts # Field utilities (create first)
components/ # React components
services/ # API service layer
fluent/
ui-pages/
page.now.ts # UiPage definition
tsconfig.json Contents
{
"compilerOptions": {
"moduleResolution": "bundler",
"module": "es2022",
"target": "es2022",
"lib": ["ES2022", "DOM"],
"jsx": "preserve"
}
}
Agent Decision Tree
When building OR editing a UI Page, follow these steps. Step 1 applies to EVERY prompt -- including follow-ups that change layouts, add views, or modify existing components:
- MANDATORY -- Load component rules (EVERY prompt): Review component selection rules immediately. Do NOT write any TSX/JSX code before completing this step.
- Create ServiceNow application (if new)
- Set proper HTML title: Include dynamic title generation based on context (Polaris iframe vs standard page)
- Read component documentation: Use package docs to read docs for components you will use
- List specific components: Explicitly state which components from
@servicenow/react-componentswill be used - Define navigation structure: If two or more views/logical entities exist (list + details, tabs, different sections), specify URLSearchParams structure (e.g.,
?view=list,?view=details&id=123,?tab=overview) - Plan dirty state tracking: If ANY forms, edits, or create views exist, implement dirty state using
useRecord().form.isDirtyand warn on navigation withModal - Create UI Page files: HTML, JSX, components, services, CSS
- MANDATORY -- Application navigator entry: Create the Application Menu and App Module
- Build and install
Decision rules:
- Listing ServiceNow records -- ALWAYS use
NowRecordListConnected, NEVER manual Table API - Viewing/editing a record -- ALWAYS use
RecordProviderwrapper, NEVER standalone inputs - Multiple views -- ALWAYS use URLSearchParams
- Any form edit/create -- ALWAYS implement dirty state tracking using
useRecord().form.isDirty - Navigation/title updates -- ALWAYS check
window.self !== window.topfor Polaris iframe detection - Build config -- NEVER create (IDE handles everything)
Component Selection and Usage
Why ServiceNow Components?
ALWAYS use @servicenow/react-components instead of bare HTML elements. ServiceNow components provide:
- Platform theming -- automatically match the instance's Polaris theme, dark mode, and branding
- Accessibility -- built-in ARIA attributes, keyboard navigation, and screen reader support
- Field type handling -- reference fields, choice lists, dates, currencies rendered correctly without custom code
- ACL enforcement -- field-level security and read-only rules applied automatically
- Dirty state tracking -- built-in form change detection via
useRecord().form.isDirty - Pagination, sorting, filtering --
NowRecordListConnectedhandles all list behavior out of the box - Consistent UX -- matches every other ServiceNow application the user interacts with
You cannot replicate this with bare HTML. A custom <input> won't respect field types, ACLs, or theming. A manual <table> with .map() won't have pagination, sorting, or inline editing.
Component Mapping
| Raw HTML | ServiceNow Component |
|---|---|
<button> | Button, ButtonIconic, ButtonBare, ButtonStateful |
<input> | Input, InputUrl |
<select> | Select, Dropdown |
<textarea> | Textarea |
<div class="card"> | Card, CardHeader, CardFooter, CardActions |
<dialog> / custom modal | Modal |
<div class="tabs"> | Tabs |
<span class="badge"> | Badge |
<img> | Image, Avatar |
<a href> | TextLink |
<div class="tooltip"> | Tooltip |
<progress> | ProgressBar |
<input type="checkbox"> | Checkbox, Toggle |
<input type="radio"> | RadioButtons |
Do NOT guess event handler prop names (e.g., onClick vs onClicked). ALWAYS read the component documentation to get the correct prop names. ServiceNow components often differ from standard React patterns:
- Text content uses
labelprop, not children - Lists use
itemsarray prop, not mapped children - Events use
onXxxSetnaming (e.g.,onSelectedItemSet) with data inevent.detail
Decision Matrix
| Use Case | Component | Never Use |
|---|---|---|
| Display list of records | NowRecordListConnected | Manual fetch() + map() + <table> |
| View/edit single record | RecordProvider + FormColumnLayout | Manual fetch() + <input> fields |
| Buttons | Button | <button> |
| Form inputs (non-record) | Input, Textarea | <input>, <textarea> |
| Dropdowns (non-record) | Select | <select> |
| Cards/panels | Card | <div className="card"> |
| Modals | Modal | Custom modal implementation |
| Tabs (UI only) | Tabs + Tab | Custom tab implementation |
For navigation between different views/pages, use URLSearchParams (?tab=overview) NOT Tabs. Use Tabs only for UI organization within a single view that doesn't need separate URLs.
Critical Anti-Patterns
Anti-Pattern 1: Manual Record Iteration. Using .map() to iterate over fetched ServiceNow records is forbidden -- use NowRecordListConnected instead. This applies to ALL data from ServiceNow tables.
// FORBIDDEN - manual fetch + map
const [records, setRecords] = useState([]);
useEffect(() => {
fetch("/api/now/table/incident")
.then(r => r.json())
.then(d => setRecords(d.result));
}, []);
return records.map(record => <div>{record.number}</div>);
// REQUIRED - use NowRecordListConnected
<NowRecordListConnected
table="incident"
listTitle="Incidents"
columns="number,short_description,priority"
onNewActionClicked={() =>
navigateToView("create", null, { title: "New Record" })
}
/>;
Anti-Pattern 2: Raw HTML Elements. Using raw HTML elements when a ServiceNow React component exists is forbidden -- use components from @servicenow/react-components instead.
Record List Pattern
ALWAYS use NowRecordListConnected for displaying ServiceNow records.
import React from "react";
import { NowRecordListConnected } from "@servicenow/react-components/NowRecordListConnected";
export default function TicketList({ onSelectTicket, onNewClicked }) {
return (
<NowRecordListConnected
table="incident"
listTitle="Incidents"
columns="number,short_description,priority,state,assigned_to"
onRowClicked={e => onSelectTicket(e.detail.payload.sys_id)}
onNewActionClicked={onNewClicked}
limit={25}
/>
);
}
Filtering: NowRecordListConnected has no query prop. To filter records, use the React key prop with an encoded query string -- this forces a re-mount with the filtered data:
<NowRecordListConnected
key="active=true^priority=1"
table="incident"
listTitle="Critical Incidents"
columns="number,short_description,priority"
/>
onNewActionClicked: REQUIRED unless hideHeader={true}. MUST navigate to the create view -- NEVER use an empty function () => {}.
For custom/creative apps, use hideHeader={true} (music library, catalog, etc.) where the standard list header doesn't fit the UI design. When hideHeader is true, onNewActionClicked is not needed.
Single Record Form Pattern
ALWAYS use RecordProvider for viewing/editing a single record.
import React from "react";
import { RecordProvider } from "@servicenow/react-components/RecordContext";
import { FormActionBar } from "@servicenow/react-components/FormActionBar";
import { FormColumnLayout } from "@servicenow/react-components/FormColumnLayout";
export default function TicketDetail({ ticketId, onBack }) {
return (
<RecordProvider
table="incident"
sysId={ticketId}
isReadOnly={false}
>
<FormActionBar />
<FormColumnLayout />
</RecordProvider>
);
}
RecordProvider usage notes:
table: ServiceNow table namesysId: Record sys_id to load (use"-1"for new records, NEVERnull/undefined)isReadOnly={false}: Required for editable forms and new recordsFormColumnLayout: Renders ALL fields automatically -- there is NORecordFieldcomponentFormActionBar: Provides save/update/delete action buttonsuseRecord(): Hook to accessform.isDirty,header.data, etc.
URL Generation and Navigation
ALWAYS use URLSearchParams for navigation. Each view MUST have its own URL. NEVER use window.location.reload() -- use React state to trigger re-renders. NEVER use hash-based routing (#/path) -- ALWAYS use query strings (?view=details).
ALWAYS implement iframe detection for Polaris compatibility:
if (window.self !== window.top) {
// Polaris iframe: Use CustomEvent.fireTop
window.CustomEvent.fireTop("magellanNavigator.permalink.set", {
relativePath: path,
title: title
});
} else {
// Standalone: Use history.pushState
window.history.pushState({}, "", path);
document.title = title;
}
Complete Navigation Example
interface ViewState {
view: string;
recordId: string | null;
}
function getViewFromUrl(): ViewState {
const params = new URLSearchParams(window.location.search);
return {
view: params.get("view") || "list",
recordId: params.get("id") || null
};
}
export default function TaskApp() {
const [currentView, setCurrentView] = useState<ViewState>(getViewFromUrl);
useEffect(() => {
const onPopState = () => setCurrentView(getViewFromUrl());
window.addEventListener("popstate", onPopState);
return () => window.removeEventListener("popstate", onPopState);
}, []);
const navigateToView = useCallback(
(viewName: string, recordId?: string | null, { title = "" } = {}) => {
const params = new URLSearchParams({ view: viewName });
if (recordId) params.set("id", recordId);
const relativePath = `${window.location.pathname}?${params}`;
const pageTitle =
title ||
`Task Manager - ${viewName.charAt(0).toUpperCase() + viewName.slice(1)}`;
if (window.self !== window.top) {
(window as any).CustomEvent.fireTop("magellanNavigator.permalink.set", {
relativePath,
title: pageTitle
});
}
window.history.pushState({ viewName, recordId }, "", relativePath);
document.title = pageTitle;
setCurrentView({ view: viewName, recordId: recordId || null });
},
[]
);
const { view, recordId } = currentView;
if (view === "list") {
return (
<NowRecordListConnected
table="incident"
listTitle="Incidents"
columns="number,short_description,priority,state,assigned_to"
onRowClicked={e => {
const sysId = e.detail.payload.sys_id;
const number = e.detail.payload.number;
navigateToView("detail", sysId, { title: `Incident ${number}` });
}}
onNewActionClicked={() => {
navigateToView("create", null, { title: "New Incident" });
}}
/>
);
}
if (view === "create") {
return (
<RecordProvider table="incident" sysId="-1" isReadOnly={false}>
<FormActionBar
onSubmit={() =>
navigateToView("list", null, { title: "Incident List" })
}
onCancel={() =>
navigateToView("list", null, { title: "Incident List" })
}
/>
<FormColumnLayout />
</RecordProvider>
);
}
if (view === "detail" && recordId) {
return (
<RecordProvider table="incident" sysId={recordId} isReadOnly={false}>
<FormActionBar
onSubmit={() =>
navigateToView("list", null, { title: "Incident List" })
}
onCancel={() =>
navigateToView("list", null, { title: "Incident List" })
}
/>
<FormColumnLayout />
</RecordProvider>
);
}
}
Updating Page Title After Record Fetch
function updatePageTitle(label: string) {
if (window.self !== window.top) {
(window as any).CustomEvent.fireTop("magellanNavigator.permalink.set", {
relativePath: window.location.pathname + window.location.search,
title: label
});
} else {
document.title = label;
}
}
Navigation Key Points
- Each view needs its own URL with URLSearchParams
- Use React state (
setCurrentView) to trigger re-renders -- NEVERwindow.location.reload() - Check
window.self !== window.topfor iframe context - Use
window.CustomEvent.fireTopin Polaris iframe - Use
history.pushState()for URL updates - Listen for
popstateevents for browser back/forward - NEVER use hash-based routing (
#/path)
SPA Patterns
Default Navigation Approach
Full SPA with URL-based routing is the DEFAULT navigation pattern for ALL UI Pages unless the user explicitly specifies otherwise.
Every UI Page application should:
- Use URLSearchParams for navigation (
?view=list,?view=details&id=123,?tab=overview) - Implement view switching based on URL parameters
- Support browser back/forward buttons via
popstateevent
When to Use SPA Architecture
SPA architecture is the DEFAULT for:
- Any application with multiple views (list, detail, edit, create)
- Multi-step forms or wizards
- Tab-based interfaces
- Complex state management across views
- Dashboard with multiple sections
- ANY UI Page unless user explicitly requests a single-view page
Shared State Management with Context
// src/client/contexts/AppContext.tsx
import React, { createContext, useState, useContext } from "react";
const AppContext = createContext();
export function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [filters, setFilters] = useState({});
const [currentView, setCurrentView] = useState("dashboard");
const navigate = view => {
setCurrentView(view);
const params = new URLSearchParams({ view });
window.history.pushState({ view }, "", `?${params}`);
};
return (
<AppContext.Provider
value={{ user, setUser, filters, setFilters, currentView, navigate }}
>
{children}
</AppContext.Provider>
);
}
export const useApp = () => useContext(AppContext);
// src/client/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { AppProvider } from "./contexts/AppContext.tsx";
import App from "./app.tsx";
ReactDOM.createRoot(document.getElementById("root")).render(
<AppProvider>
<App />
</AppProvider>
);
SPA Best Practices
- Keep route components under 80 lines
- Use URLSearchParams-based routing (no router library dependencies)
- Centralize API calls in a service layer
- Use React Context for shared state across views
- Maintain a single HTML entry point
- Each logical view/tab MUST have its own URL parameter
- Support browser back/forward navigation with
popstateevent listener
Avoidance
- Never use raw HTML elements (
<button>,<input>,<select>) when@servicenow/react-componentsprovides equivalents - Never use standalone Input components for ServiceNow record operations -- ALWAYS use
NowRecordListConnectedfor lists andRecordProvider+FormColumnLayoutfor forms. There is noRecordFieldcomponent - Never use hash-based routing (
#/path) -- ALWAYS use URLSearchParams with query strings (?view=details) - Never skip iframe detection (
window.self !== window.top) -- ALWAYS implement Polaris compatibility for navigation and title updates - Never assume standard React patterns for ServiceNow components -- always read the component docs first
- Never create build configuration files (webpack, vite, babel)
- Never use CSS Modules (
.module.css) or@importin CSS files - Never use CDNs or external script sources
- Never use GlideAjax, g_form, Jelly, or
<g:script>tags - Never add
client_scriptorprocessing_scriptfields - Never inline JavaScript in HTML or use
onclickhandlers - Never skip the
X-UserTokenheader in API calls
API Reference
For the full property reference, see the uipage-api topic.
Templates and Examples
Minimal Starter Template
Fields Utility (ALWAYS CREATE FIRST)
// src/client/utils/fields.ts
export const display = field => field?.display_value || "";
export const value = field => field?.value || "";
UI Page Definition
// src/fluent/ui-pages/page.now.ts
import "@servicenow/sdk/global";
import { UiPage } from "@servicenow/sdk/core";
import page from "../../client/index.html";
export const my_page = UiPage({
$id: Now.ID["my-page"],
endpoint: "x_app_page.do",
html: page,
direct: true
});
HTML Entry (with Array.from Polyfill)
<!-- src/client/index.html -->
<html class="-polaris">
<head>
<title>My Page</title>
<sdk:now-ux-globals></sdk:now-ux-globals>
<!-- Array.from polyfill to fix prototype.js breaking iterables (Set, Map, etc.) -->
<!-- MUST be inline script BEFORE module scripts - ESM imports are hoisted so external polyfill files won't work -->
<script type="text/javascript">
//<![CDATA[
(function () {
var testWorks = (function () {
try {
var result = Array.from(new Set([1, 2]));
return (
Array.isArray(result) && result.length === 2 && result[0] === 1
);
} catch (e) {
return false;
}
})();
if (testWorks) return;
var originalArrayFrom = Array.from;
function specArrayFrom(arrayLike, mapFn, thisArg) {
if (arrayLike == null)
throw new TypeError(
"Array.from requires an array-like or iterable object"
);
var C = this;
if (typeof C !== "function" || C === Window || C === Object) {
C = Array;
}
var mapping = typeof mapFn === "function";
var iterFn = arrayLike[Symbol.iterator];
if (typeof iterFn === "function") {
var result = [];
var i = 0;
var iterator = iterFn.call(arrayLike);
var step;
while (!(step = iterator.next()).done) {
result[i] = mapping
? mapFn.call(thisArg, step.value, i)
: step.value;
i++;
}
result.length = i;
return result;
}
var items = Object(arrayLike);
var len = Math.min(
Math.max(Number(items.length) || 0, 0),
Number.MAX_SAFE_INTEGER
);
var result = new C(len);
for (var k = 0; k < len; k++) {
result[k] = mapping ? mapFn.call(thisArg, items[k], k) : items[k];
}
result.length = len;
return result;
}
Array.from = function (arrayLike, mapFn, thisArg) {
if (
arrayLike != null &&
typeof arrayLike[Symbol.iterator] === "function"
) {
try {
return specArrayFrom.call(this, arrayLike, mapFn, thisArg);
} catch (e) {
console.error("Array.from failed with error:", e);
return originalArrayFrom.call(this, arrayLike, mapFn, thisArg);
}
}
return originalArrayFrom.call(this, arrayLike, mapFn, thisArg);
};
})();
if (window.Element && Element.Methods) {
Element.Methods.remove = function (element) {
element = $(element);
if (element.parentNode) {
element.parentNode.removeChild(element);
}
return element;
};
Element.addMethods();
}
//]]>
</script>
<script
src="main.tsx?uxpcb=$[UxFrameworkScriptables.getFlushTimestamp()]"
type="module"
></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
IMPORTANT NOTES:
- The Array.from polyfill MUST be an inline
<script>tag (nottype="module") placed BEFORE the module script. ESM imports are hoisted and execute before any inline code in the module, so importing a polyfill file won't work. - The
//<![CDATA[and//]]>wrappers are required to prevent Jelly from parsing JavaScript operators like<and&&as XML syntax.
React Bootstrap
// src/client/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./app";
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
Common User Requests Mapping
| User Request | Agent Implementation |
|---|---|
| "Create a ticket management interface" | React with state-based routing and Table API |
| "Build a form to submit requests" | React form component with POST to Table API |
| "Dashboard showing metrics" | Multiple React components with aggregated queries |
| "Multi-step wizard" | State-based SPA with shared context |
| "Tab interface" | Switch statement with currentView state |
| "Add React Router" | "Use built-in state-based routing instead" |
| "Add webpack configuration" | "The build system handles this automatically" |