Widget structure
Every Service Portal widget has four parts:
- HTML Template — the rendered markup using AngularJS binding syntax
- Client Controller — AngularJS controller for client-side logic and UI interactions
- Server Script — runs on the server when the widget loads, populates the data object
- CSS/SCSS — widget-specific styles scoped to this widget
Data flow: server to client on load
The server script runs first when the widget loads. It populates the data object. This data is then available in the client controller via c.data:
// Server Script — runs on load
(function() {
data.incidents = [];
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^assigned_to=javascript:gs.getUserID()');
gr.orderBy('priority');
gr.setLimit(10);
gr.query();
while (gr.next()) {
data.incidents.push({
sys_id: gr.getUniqueValue(),
number: gr.getValue('number'),
short_description: gr.getValue('short_description'),
state: gr.getDisplayValue('state'),
priority: gr.getValue('priority')
});
}
data.count = data.incidents.length;
})();
// HTML Template — AngularJS bindings
// <div>
// <p>You have {{c.data.count}} open incidents</p>
// <div ng-repeat="inc in c.data.incidents">
// <strong>{{inc.number}}</strong> — {{inc.short_description}}
// </div>
// </div>
Client-to-server communication
To call the server from the client (after initial load), use c.server.get():
// Client Controller
api.controller = function($scope) {
var c = this;
c.refreshData = function(filter) {
c.server.get({action: 'refresh', filter: filter})
.then(function(response) {
c.data.incidents = response.data.incidents;
c.data.count = response.data.count;
});
};
};
// Server Script — handle the action
(function() {
if (input.action == 'refresh') {
data.incidents = [];
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true^state=' + input.filter);
gr.setLimit(10);
gr.query();
while (gr.next()) {
data.incidents.push({
number: gr.getValue('number'),
state: gr.getDisplayValue('state')
});
}
data.count = data.incidents.length;
}
})();
AngularJS binding syntax
<!-- Display a value -->
{{c.data.fieldName}}
<!-- Loop over an array -->
<div ng-repeat="item in c.data.items">{{item.name}}</div>
<!-- Show/hide based on condition -->
<div ng-if="c.data.showSection">...</div>
<!-- Click handler -->
<button ng-click="c.handleClick(item)">Action</button>
<!-- CSS class binding -->
<div ng-class="{'active': c.data.isActive}">...</div>
Service Portal vs Next Experience
Service Portal (AngularJS) is the established framework with the largest library of existing widgets and community examples. Next Experience (UIB — UI Builder) is the newer React-based framework ServiceNow is investing in going forward. For new portal development in 2026, check whether your instance supports Next Experience and whether it fits your use case before committing to Service Portal widgets.
Related guides: GlideRecord performance — writing efficient server scripts · Client Scripts — the non-portal equivalent · GlideAjax — similar client/server pattern
Widget anatomy — server script, client controller, HTML template
Every Service Portal widget has three components that run in different contexts. The Server Script runs on the server when the widget loads, populates the data object with everything the widget needs, and has full access to GlideRecord and the ServiceNow server APIs. The Client Controller is an AngularJS controller running in the browser — it reads from data, handles user interactions, and calls GlideAjax for dynamic server requests. The HTML Template uses AngularJS binding syntax ({{c.data.fieldName}}) to render data into UI.
// Server Script — runs on load
(function() {
var inc = new GlideRecord('incident');
inc.addEncodedQuery('caller_id=' + gs.getUserID() + '^active=true');
inc.addFieldToSelect(['number','short_description','state','priority']);
inc.orderByDesc('opened_at');
inc.setLimit(5);
inc.query();
data.incidents = [];
while (inc.next()) {
data.incidents.push({
number: inc.getValue('number'),
title: inc.getValue('short_description'),
state: inc.getDisplayValue('state'),
priority: inc.getDisplayValue('priority')
});
}
})();
// HTML Template
<ul>
<li ng-repeat="inc in c.data.incidents">
{{inc.number}} — {{inc.title}} ({{inc.state}})
</li>
</ul>
Dynamic widget interactions with spModal and GlideAjax
For actions after the initial load — a user submitting a form, clicking a button, requesting updated data — the client controller calls GlideAjax to a Script Include, receives a JSON response, and updates c.data to trigger Angular's two-way binding and re-render the template. ServiceNow's spModal service handles modal dialogs inside the portal without a page navigation — useful for confirmation dialogs, inline forms, and detail views that should not leave the current page context.
Widget CSS and Bootstrap integration
Service Portal uses Bootstrap 3 for layout. Widget CSS is scoped to the widget — styles you define in the CSS field only apply within that widget's DOM, not globally. Use Bootstrap grid classes (col-sm-6, col-md-4) for responsive layouts. For custom styling that needs to apply globally across a portal theme, use the Portal Theme's CSS variables rather than widget-level CSS. Inline styles in the HTML template should be avoided — they are hard to maintain and cannot be overridden by theme variables.
Performance considerations for widgets
Heavy server scripts on widgets that load on every page (header widgets, navigation widgets, dashboard widgets) affect every page load for every user. Apply the same discipline as any server-side script: use GlideRecord with setLimit and addFieldToSelect, use GlideAggregate for counts, and cache data that changes infrequently. A widget that queries 200 records on every page load multiplied by 500 concurrent users is a guaranteed performance problem.
Related: GlideAjax · Script Includes · Client Scripts · GlideRecord performance · ACLs — portal security model
Widget security — what developers miss
Service Portal widgets are accessible to portal users, who may have different (usually more restricted) access than internal users. The widget server script runs as the logged-in user's context — if a portal user without the itil role tries to view incident data through a widget that queries the incident table, the GlideRecord query returns no results because the user's ACLs deny access. This is correct behaviour — do not try to bypass it by running queries as a privileged user just to make the widget work. Instead, design portal widgets to surface only the data the portal user is legitimately allowed to see, using the same ACL framework that governs all platform access.
For anonymous portal widgets (accessible without login), the server script runs in the guest user context. Any data returned by an anonymous widget is publicly accessible data — never return internal records, sys_ids of sensitive records, or any information that should not be publicly visible. Design anonymous widgets conservatively; authenticated widgets designed for self-service users slightly less conservatively; internal portal widgets for IT staff at the same level as standard form access.
Testing and debugging widgets
The Service Portal Widget Editor (accessible via sp_config or the Widget Editor application) provides real-time preview of widget changes. Server script errors appear in the browser console and the System Log. Use console.log() in the client controller for client-side debugging. For server script debugging, add data.debug = { query: 'your debug info' } and inspect it in the browser using the Angular scope inspector or by temporarily rendering it in the template. The most common bugs are: the server script throws an error (check System Log), an Angular scope variable is undefined (check data object population in server script), or a GlideAjax call returns null (check that the Script Include is client-callable and the method name matches exactly).
Mobile responsiveness and accessibility
Service Portal is used on mobile devices, particularly in field service and HR self-service use cases. For the field service side specifically, see our FSM guide. Widgets that use Bootstrap's grid system (col-xs-12 col-sm-6 col-md-4) respond to screen size automatically. Avoid fixed-width layouts that assume desktop viewports. For widgets with forms, ensure tap targets are large enough for touch interaction (minimum 44px), input fields are appropriately sized, and keyboard handling works correctly for mobile users. Accessibility matters too — use semantic HTML (proper heading hierarchy, label elements for inputs, alt text for any images), ensure colour contrast meets WCAG AA standards (particularly important on a dark-themed portal), and test with a screen reader if your portal serves users who rely on assistive technology.
Widget caching strategies
Service Portal has built-in server-side caching for widgets that renders HTML once and serves it from cache for subsequent requests within the cache TTL. Enable caching on the Widget record (Cache field) for widgets whose output does not change per user — informational widgets, FAQ lists, static content widgets. Disable caching for widgets that show user-specific data or real-time information. When caching is enabled, the server script runs once per cache window rather than once per page load, which can dramatically reduce database load for heavily-visited portal pages. The cache key includes the widget sys_id and any instance options — widgets with different options configurations maintain separate caches.