Record Navigation in ServiceNow Service Portal

How to Implement Previous & Next Record Navigation in ServiceNow Service Portal
Navigating between records in the ServiceNow Service Portal is not as straightforward as in the classic UI.
In Classic UI, when you open a record from a filtered list, the platform automatically remembers:
- The filter
- The sorting (ASC / DESC)
- The record position
But in Service Portal, this behavior does not exist out of the box.
- Previous / Next record navigation
- Respecting list filters dynamically
- Respecting sorting (ASC & DESC)
- SPA-safe navigation (no page refresh required)
- Embedded directly inside the Form Widget
- Production-ready and reusable
This solution was implemented in a real-world GDPR module and works reliably across tables.
Context and preparation
This article was inspired by the widget "Service Portal Next and Previous feature on form'', which I found in this ServiceNow community article.
That widget allows the user to navigate to the next and previous records without navigating to the list view. This idea originates from the Classic UI form, allowing you to go to the next and previous record in the list.
Similarly, we want to add this feature to the Service Portal form view.
You can achieve that with the widget above easily. However, the functionality of that widget is simple. It doesn't take into account the list filter or the order of the number of records (ASC or DESC). Regarding enabling Edit Filters on the List for End Users in the Service Portal, this tutorial walks you through how to achieve that.
If you want to have a more advanced version of that widget's functionality that takes the above into account, then this article is for you.
First note:
Instead of extending that widget, I have embedded the implementation of the code/script directly in the default ServiceNow widgets themselves - list widget & form widget. This is also a best practice to keep the code tight. Additionally, you'll avoid working with formatters, etc., that are described in the documentation of the feature.
Second note:
Add the Data Table from Instance Definition widget to the catalog home page in the Service Portal:
Best practice is to clone the widget.
Third note:
Clone the form widget and add it to the form page:
Fourth note:
The implementation supports list filters. So, you need to enable it in the list. This tutorial walks you through how exactly you do that.
After enabling the list filter, you should have a list with a filter option:
Now, let me walk you through how to implement the Previous & Next Record Navigation in the Service Portal.
The Problem
When opening a record from a list in Service Portal:
- The filter context is lost
- The order direction is not preserved
- Navigation buttons (if added) don’t respect the list
- SPA navigation prevents server re-execution unless handled properly
Result:
Navigation becomes inconsistent, broken, or requires manual page refresh.
Architecture Overview
The solution consists of three core parts:
- List Widget Enhancement
- Pass filter and order parameters to the form
- Form Widget Server Logic
- Reconstruct the ordered result set
- Determine previous and next records
- Form Widget Client Logic
- Navigate SPA-safe
- Force server re-execution correctly
List Widget – Passing Filter & Sorting Context
When clicking a record in the list, we pass:
- sysparm_query
- sysparm_orderby
- sysparm_orderby_desc
spa=1
List Widget Client Controller
function($scope, spUtil, $location, spAriaFocusManager) {
// $scope.$on('data_table.click', function(e, parms){
// var p = $scope.data.page_id || 'form';
// var s = {id: p, table: parms.table, sys_id: parms.sys_id, view: 'sp'};
// var newURL = $location.search(s);
// spAriaFocusManager.navigateToLink(newURL.url());
// });
// START: Prev/Next Buttons
function getDT() {
return ($scope.data && $scope.data.dataTableWidget) ? $scope.data.dataTableWidget : null;
}
function pickFirst() {
for (var i = 0; i < arguments.length; i++) {
if (arguments[i] !== undefined && arguments[i] !== null && arguments[i] !== '')
return arguments[i];
}
return '';
}
$scope.$on('data_table.click', function(e, parms) {
var formPage = $scope.data.page_id || 'form';
var urlParams = $location.search();
var dt = getDT();
// Try all likely places where Data Table stores the *current* query
var dtQuery =
(dt && dt.data && (dt.data.encodedQuery || dt.data.filter || dt.data.query)) ||
(dt && dt.options && (dt.options.filter || dt.options.query)) ||
'';
// URL can also carry it (depends on instance/version/config)
var urlQuery = urlParams.sysparm_query || urlParams.filter || '';
var encodedQuery = pickFirst(dtQuery, urlQuery, $scope.data.filter);
// Ordering: prefer URL (if present), otherwise widget options
var orderByField = pickFirst(
urlParams.sysparm_orderby,
urlParams.o,
(dt && dt.options && dt.options.o),
$scope.data.o,
'number'
);
var orderDirection = pickFirst(
urlParams.d,
(dt && dt.options && dt.options.d),
$scope.data.d,
'asc'
);
var navigationParams = {
id: formPage,
table: parms.table,
sys_id: parms.sys_id,
view: 'sp',
sysparm_query: encodedQuery,
sysparm_orderby: orderByField,
spa: 1
};
if (orderDirection === 'desc')
navigationParams.sysparm_orderby_desc = 'true';
// var newURL = $location.search(navigationParams);
// spAriaFocusManager.navigateToLink(newURL.url());
$location.search(navigationParams);
spAriaFocusManager.navigateToLink($location.url());
});
// END: Prev/Next Buttons
}
Add the above script to the client controller list widget:

Now the form receives the full list context.
Form Widget Server Script – Building Prev / Next Logic
This is the core logic.
/// ===== START PREV / NEXT LOGIC =====
(function() {
data.listQuery = '';
data.orderBy = 'number';
data.orderDesc = false;
// Prefer SPA input first
// --- Read filter context (support both filter and sysparm_query) --
// data.listQuery = (input && input.sysparm_query) || '';
// data.orderBy = (input && input.sysparm_orderby) || 'number';
// data.orderDesc = (input && input.sysparm_orderby_desc) == 'true';
data.listQuery =
(input && input.sysparm_query) ||
$sp.getParameter('sysparm_query') ||
'';
data.orderBy =
(input && input.sysparm_orderby) ||
$sp.getParameter('sysparm_orderby') ||
'number';
data.orderDesc =
((input && input.sysparm_orderby_desc) ||
$sp.getParameter('sysparm_orderby_desc')) == 'true';
// data.debugURLQuery = $sp.getParameter('sysparm_query');
buildPrevNext();
})();
function buildPrevNext() {
if (!data.table || !data.sys_id)
return;
var gr = new GlideRecordSecure(data.table);
// Apply the same filter as the list
if (data.listQuery)
gr.addEncodedQuery(data.listQuery);
// Apply the same ordering as the list
if (data.orderDesc) {
gr.orderByDesc(data.orderBy);
gr.orderByDesc('sys_id'); // IMPORTANT: tie-breaker desc too
} else {
gr.orderBy(data.orderBy);
gr.orderBy('sys_id'); // tie-breaker asc
}
gr.query();
var ids = [];
while (gr.next()) {
ids.push(String(gr.getUniqueValue()));
}
var current = String(data.sys_id);
var idx = ids.indexOf(current);
if (idx === -1) {
data.prevSysId = null;
data.nextSysId = null;
} else {
data.prevSysId = (idx > 0) ? ids[idx - 1] : null;
data.nextSysId = (idx < ids.length - 1) ? ids[idx + 1] : null;
}
// Send debug to the browser (since you said gs.info isn't showing)
data.prevNextDebug = {
table: data.table,
sys_id: data.sys_id,
listQuery: data.listQuery,
orderBy: data.orderBy,
orderDesc: data.orderDesc,
total: ids.length,
index: idx,
prev: data.prevSysId,
next: data.nextSysId
};
}
/// ===== END PREV / NEXT LOGIC =====
Between the snippet code:
data.f = $sp.getForm(data.table, data.sys_id, data.query, data.view, isPopup);
And:
if (data.f && data.f._fields) {
for (var key in data.f._fields) {
if ((data.f._fields[key].type === 'script' || data.f._fields[key].type === 'script_plain') && !data.f._fields[key].attributes.client_script) {
if (typeof data.esLatestVersion === 'undefined')
data.esLatestVersion = data.sys_id == "-1" || $sp.shouldUseESLatest(data.table, data.sys_id);
data.f._fields[key].useEsLatest = data.esLatestVersion;
}
}
}
Add the Prev / Next Logic.
Form Widget Client Controller – SPA-Safe Navigation
The critical mistake most developers make:
They only update $location.search()
But do not force server re-execution.
That breaks Prev/Next until page refresh.
First Implementation
// --- START: Previous / Next navigation for embedded buttons ---
$scope.$watchGroup(
['data.prevSysId', 'data.nextSysId'],
function() {
// UP arrow = previous record in list
$scope.upTarget = $scope.data.prevSysId || null;
// DOWN arrow = next record in list
$scope.downTarget = $scope.data.nextSysId || null;
}
);
// --- FIX: normalize order direction for Prev / Next ---
(function normalizeOrderDirection() {
var params = $location.search();
// If list says desc (d=desc) but sysparm_orderby_desc is missing
if (params.d === 'desc' && !params.sysparm_orderby_desc) {
params.sysparm_orderby_desc = 'true';
$location.search(params).replace();
}
// Sync into widget data so server logic is correct
if (params.sysparm_orderby_desc === 'true') {
$scope.data.orderDesc = true;
}
})();
// --- END: Previous / Next navigation for embedded buttons ---
Add the above code snippet between line:
window.location.replace(downloadLink);
And:
spUtil.recordWatch($scope, "sys_attachment", "table_sys_id=" + tableId, function(response, data)
// --- START: Previous / Next navigation for embedded buttons ---
var c = this;
c.goToRecord = function(sysId) {
if (!sysId || sysId === $scope.data.sys_id)
return;
var params = angular.copy($location.search());
params.table = $scope.data.table;
params.sys_id = sysId;
params.spa = 1;
// update URL (keeps list context)
$location.search(params).replace();
// update widget model and ask server to re-render form + prev/next
$scope.data.sys_id = sysId;
return $scope.server.update({
table: params.table,
sys_id: sysId,
sysparm_query: params.sysparm_query,
sysparm_orderby: params.sysparm_orderby,
sysparm_orderby_desc: params.sysparm_orderby_desc
});
};
// --- END: Previous / Next navigation for embedded buttons ---
Add the above snippet between:
// var ctrl = this;
And:
// switch forms
var unregister = $scope.$on('$sp.list.click', onListClick);
But make sure that you comment the var ctrl = this;
Now:
- URL updates
- Server script re-runs
- Prev/Next recalculates instantly
- No page refresh required
HTML Template
<div class="pull-right m-l-sm">
<button class="btn btn-default"
ng-disabled="!upTarget"
ng-click="c.goToRecord(upTarget)"
title="Previous record"
aria-label="Previous record">
<span class="glyphicon glyphicon-arrow-up"></span>
</button>
<button class="btn btn-default"
ng-disabled="!downTarget"
ng-click="c.goToRecord(downTarget)"
title="Next record"
aria-label="Next record">
<span class="glyphicon glyphicon-arrow-down"></span>
</button>
</div>
Add the HTML template before the attachment feature:
The HTML template should be mapped with the First Implementation of the Form Widget Client Controller (see above).
Final Result
Your navigation now:
- Respects dynamic list filters
- Respects ASC / DESC sorting
- Works without page refresh
- Works in SPA mode
- Stable ordering with tie-breaker
- Easy to replicate on any table
Why This Approach Is Superior
Instead of comparing field values (number > currentNumber), like it is implemented in the widget above, we reconstruct the actual ordered result set.
That guarantees:
- Identical behavior to the list view
- No edge-case bugs
- No direction confusion
- No disabled button anomalies
Reuse Strategy
To implement this on another instance:
- Copy list click enhancement
- Copy Prev/Next server block
- Copy SPA-safe navigation method
- Add HTML buttons
- Ensure sysparm parameters are passed
That’s it.
Closing Thoughts
Service Portal is powerful — but SPA behavior changes how navigation works.
By correctly passing context, rebuilding the order on the server, and forcing server-side re-execution on the client, you can build enterprise-grade navigation within any custom form.
If you're building complex portals — especially GDPR, case management, or record-heavy apps — this pattern is essential.







Comments
Post a Comment