---
tags: [service portal, portal, widget, theme, page, sp_portal, sp_widget, sp_page, sp_theme, AngularJS, Bootstrap 3, self-service, branding, layout, containers, rows, columns]
---
# Service Portal

Guide for building ServiceNow Service Portal experiences using the Fluent API. Service Portal is a portal framework for building user-facing self-service experiences using AngularJS and Bootstrap 3. This guide covers core portal concepts: portals, pages, widgets, and themes.

## When to Use

- Building a branded self-service portal for employees, customers, or partners
- Creating custom portal pages with responsive layouts
- Developing interactive widgets with client-server communication
- Customizing portal appearance with themes, headers, and footers
- Setting up navigation menus for portal structure
- Creating shared AngularJS services, directives, and factories for widgets
- Managing external JavaScript and CSS library dependencies for widgets

## When NOT to Use

- Platform UI Pages -- use the UI Page skill instead
- Next Experience / UI Builder pages -- different framework entirely
- Backend scripts with no UI component (business rules, script includes, etc.)
- Flow Designer workflows
- Workspace components in Next Experience

## Instructions

1. **Always use dedicated Fluent APIs.** Every Service Portal component has a dedicated API (`ServicePortal`, `SPPage`, `SPWidget`, `SPTheme`, `SPMenu`, `SPAngularProvider`, `SPWidgetDependency`). Never use `Record()` / `Now.table()` for Service Portal components.
2. **Check OOTB components first.** Before creating any widget, theme, or page, check whether an out-of-the-box equivalent already exists. Only create custom components when no suitable OOTB option exists.
3. **Verify uniqueness.** `urlSuffix` (portals), `pageId` (pages), and widget `name`/`id` must be unique across the instance. Always query the instance to verify before creating.
4. **Bootstrap 3 only.** Service Portal uses Bootstrap 3 (not 4 or 5). Use `.panel`, `.btn-default`, `.col-xs-*` through `.col-lg-*`, and the 12-column grid.
5. **AngularJS 1.x only.** Use controller alias `c` (not `$scope`), `ng-repeat`, `ng-click`, `ng-model`. No Angular 2+ syntax.
6. **Separate files for widgets.** Always use `Now.include()` for widget scripts, templates, and styles. Never inline large code blocks.
7. **No placeholders.** Replace all placeholder values with actual data or queried sys_ids.
8. **Query only when referencing existing components.** Do not query before creating new ones.
9. **Scoped app restrictions.** Fluent apps run in scoped context. Use scoped-safe APIs only (e.g., `new GlideDateTime()` instead of `nowDateTime()`).
10. **Run UI diagnostics after install.** After a successful install, verify the portal loads without errors at `/<urlSuffix>?id=<homepagePageId>`.
11. **Always share the portal URL.** After successful install, provide the complete URL: `https://<instance>.service-now.com/<urlSuffix>?id=<homepagePageId>`.

## Key Concepts

### Component Hierarchy

```
Portal (sp_portal)
+-- Theme (sp_theme)
|   +-- Header (sp_header_footer)
|   +-- Footer (sp_header_footer)
+-- Main Menu (sp_instance_menu)
+-- Pages (sp_page)
    +-- Containers -> Rows -> Columns -> Widget Instances (sp_instance)
        +-- Widgets (sp_widget)
            +-- Dependencies (sp_dependency)
            +-- Angular Providers (sp_angular_provider)
```

### API to Table Mapping

| Component         | Fluent API             | ServiceNow Table      |
| ----------------- | ---------------------- | --------------------- |
| Portal            | `ServicePortal()`      | `sp_portal`           |
| Page              | `SPPage()`             | `sp_page`             |
| Widget            | `SPWidget()`           | `sp_widget`           |
| Theme             | `SPTheme()`            | `sp_theme`            |
| Menu              | `SPMenu()`             | `sp_instance_menu`    |
| Angular Provider  | `SPAngularProvider()`  | `sp_angular_provider` |
| Widget Dependency | `SPWidgetDependency()` | `sp_dependency`       |
| Header/Footer     | `Record()`             | `sp_header_footer`    |
| CSS Include       | `CssInclude()`         | `sp_css_include`      |
| JS Include        | `JsInclude()`          | `sp_js_include`       |

### File Organization

```
fluent/
 +-- service-portal/
 |   +-- portal.now.ts
 +-- sp-page/
 |   +-- home/
 |   |   +-- home-page.now.ts
 |   +-- login/
 |       +-- login-page.now.ts
 +-- sp-widget/
 |   +-- my_widget/
 |       +-- widget.now.ts
 |       +-- server_script.js
 |       +-- client_script.js
 |       +-- template.html
 |       +-- styles.css
 +-- sp-theme/
 |   +-- theme.now.ts
 +-- sp-instance-menu/
 |   +-- menu.now.ts
 +-- sp-angular-provider/
 |   +-- provider.now.ts
 +-- sp-dependency/
 |   +-- dependency.now.ts
 +-- sp-header-footer/
     +-- header.now.ts
```

### Important Technologies

Service Portal uses **Bootstrap 3** (not 4/5) and **AngularJS 1.x** (not Angular 2+):

- **Bootstrap 3:** 12-column grid, `.panel`, `.btn-default`, `.col-xs-*` through `.col-lg-*`
- **AngularJS:** controller alias `c` (not `$scope`), `ng-repeat`, `ng-click`, `ng-model`

### AngularJS Binding Reference

| Pattern                 | Usage                                                  |
| ----------------------- | ------------------------------------------------------ |
| Two-way data binding    | `ng-model="c.data.fieldName"`                          |
| Display only            | `ng-bind="c.data.value"` or `{{c.data.value}}`        |
| Remove from DOM         | `ng-if="c.data.showSection"`                           |
| Hide/show (keep in DOM) | `ng-show="c.loading"`                                  |
| List iteration          | `ng-repeat="item in c.data.rows track by item.sys_id"` |
| Click handler           | `ng-click="c.methodName()"`                            |
| Dynamic class           | `ng-class="{'sp-active': c.selected === item.sys_id}"` |
| Disable button          | `ng-disabled="c.submitting"`                           |
| Input validation state  | `ng-class="{'has-error': c.errors.fieldName}"`         |

### Widget Script Communication

**Server script context:**

- `data` -- object passed to client
- `input` -- client input from `c.server.get()` / `c.server.update()`
- `options` -- widget option values

**Client script context:**

- `c.data` -- data from server
- `c.options` -- widget options
- `c.server.get(input)` -- call server (GET)
- `c.server.update()` -- call server (POST, sends `c.data` as `input`)
- `c.server.refresh()` -- reload widget

## Avoidance

- **Never use `Record()` for Service Portal components** -- every component has a dedicated API. `Record()` bypasses validation and uses incorrect field mapping.
- **Never use GlideAjax in widgets** -- use `c.server.get()` instead.
- **Never omit `setLimit()` on GlideRecord queries** -- runaway queries impact performance.
- **Never omit `track by` on `ng-repeat`** -- always use `track by item.sys_id` or `track by $index`.
- **Never use Bootstrap 4/5 classes** -- Service Portal uses Bootstrap 3 only.
- **Never use `$scope` directly** -- use controller alias `c` (`var c = this`).
- **Never use hardcoded hex colors in widget CSS** -- always use theme SCSS variables.
- **Never use raw `px` values for `font-size`** -- use `$sp-text-*` or `$font-size-*` variables.
- **Never use `!important` in widget CSS** -- increase selector specificity instead.
- **Never use inline `style=""` attributes** -- use SCSS classes.
- **Never re-add bundled libraries** (jQuery, AngularJS, Bootstrap) -- they are already included in Service Portal.
- **Never use `includeOnPageLoad: true`** on dependencies unless truly global -- link to specific widgets instead.
- **Never use `href="#"` on anchor elements** -- use `ng-click` with a button instead.
- **Never use `alert()` or `console.log()` in widgets** -- use `sp-alert` component and `gs.error()` / `gs.info()` server-side.

## Implementation Workflow

1. **Understand requirements** -- identify components needed and their relationships. Use the decision trees below.
2. **Check OOTB reusability** -- check OOTB widgets, themes, and pages before creating any custom component.
3. **Create components bottom-up** -- start with dependencies and providers, then widgets, then pages, then themes and menus, finally the portal.
4. **Verify unique identifiers** -- query the instance to confirm `urlSuffix`, `pageId`, and widget names are unique.
5. **Validate configuration** -- verify all references are valid and no `Record()` API usage.
6. **Generate code** -- use dedicated Fluent API constructors, include all required fields, replace all placeholders.
7. **Run UI diagnostics** -- after install, verify the portal loads without errors.
8. **Share the portal URL** with the user.

## Decision Trees

### Portal

```
"Create/update a portal"
+-- Existing portal?
|   +-- YES -> Query sp_portal, update only changed fields
|   +-- NO  -> Use ServicePortal() to create new
+-- Theme?
|   +-- DEFAULT -> Always use OOTB theme (Coral first, La Jolla second)
|   +-- User specifies custom branding OOTB cannot satisfy -> Create SPTheme()
+-- Navigation/menu?
    +-- YES -> Create SPMenu() (theme must have header set)
    +-- NO  -> Skip
```

### Widget

```
"Create/update a widget"
+-- Existing widget satisfies the need?
|   +-- YES -> Query sp_widget, reuse it
|   +-- NO  -> Create new with separate files (Now.include())
+-- Needs external libraries?
|   +-- YES -> SPWidgetDependency() + SPWidget()
|   +-- NO  -> SPWidget() only
+-- Needs shared services/directives?
|   +-- YES -> SPAngularProvider() + SPWidget()
|   +-- NO  -> SPWidget() only
```

### Theme

```
"Create/update a theme"
+-- Portal already has a theme?
|   +-- YES -> Use existing theme. Only update customCss if user explicitly requests branding changes
|   +-- NO  -> Check OOTB themes (Coral first). Create custom only if no suitable OOTB option exists
```

---

## API Reference: Portal (sp_portal)

For the full property reference, see the `serviceportal-api` topic.

### Portal Guidelines

- Always query `sp_portal` by `url_suffix` before creating to verify the suffix is unique
- Only one portal should have `defaultPortal: true`
- Page references can be sys_id strings or SPPage object references
- M2M relationships (catalogs, knowledge bases) use arrays of objects, not arrays of strings
- Menu display requires all three: portal `theme` + theme `header` + portal `mainMenu`

### Portal Example

```typescript fluent
import "@servicenow/sdk/global";
import { ServicePortal } from "@servicenow/sdk/core";

export const employeePortal = ServicePortal({
  $id: Now.ID["employee_portal"],
  title: "Employee Portal",
  urlSuffix: "emp",
  theme: theme,       // SPTheme object or sys_id
  mainMenu: menu,     // SPMenu object or sys_id
  homePage: homePage,  // SPPage object or sys_id
  catalogs: [{ catalog: "<catalog_sys_id>", order: 100, active: true }],
  knowledgeBases: [{ knowledgeBase: "<kb_sys_id>", order: 100, active: true }]
});
```

---

## API Reference: Page (sp_page)

For the full property reference (pages, containers, rows, columns, instances), see the `sppage-api` topic.

### Page Guidelines

- `pageId` is globally unique across ALL Service Portal pages on the instance. Never use bare generics like `"home"` or `"dashboard"` -- always prefix with a portal identifier (e.g., `"hr-home"`, `"esc-request-catalog"`)
- Always query `sp_page` by `id=<candidate-pageId>` before creating
- Use `container` width for centered content, `container-fluid` for full-width backgrounds
- Column sizes in a row must sum to 12
- Use `nestedRows` for complex multi-level layouts within a single column
- Background images use `Now.attach('./path/to/image.png')` with `backgroundStyle: 'cover'` for hero banners

### Page Example

```typescript fluent
import "@servicenow/sdk/global";
import { SPPage } from "@servicenow/sdk/core";

export const homePage = SPPage({
  pageId: "hr-home",
  title: "Home",
  public: true,
  containers: [
    {
      $id: Now.ID["home_hero_container"],
      name: "Hero Section",
      width: "container-fluid",
      backgroundImage: Now.attach("./images/hero-bg.jpg"),
      backgroundStyle: "cover",
      order: 1,
      rows: [
        {
          $id: Now.ID["home_hero_row"],
          order: 1,
          columns: [
            {
              $id: Now.ID["home_hero_col"],
              size: 12,
              instances: [
                {
                  $id: Now.ID["home_hero_instance"],
                  widget: heroWidget,
                  order: 1
                }
              ]
            }
          ]
        }
      ]
    }
  ]
});
```

---

## API Reference: Widget (sp_widget)

For the full property reference, see the `spwidget-api` topic.

### Widget Guidelines

- Widget `name` and `id` must be unique across the instance -- always query before creating
- Always use separate files via `Now.include()` for scripts, templates, and styles
- Client script pattern: bare function with `api.controller`, `var c = this`, no IIFE
- Templates always use controller alias `c`: `{{c.data.field}}`, never `{{data.field}}`
- Never use GlideAjax -- use `c.server.get()` instead
- Every GlideRecord query must have `setLimit()`
- Every `ng-repeat` must have `track by`
- Initialize all `data.*` properties at the top of server script
- Use `optionSchema` for configurable behavior instead of hardcoded values
- No raw hex in widget CSS -- use theme SCSS variables

### Scoped Application Restrictions

Fluent apps run in scoped context. Global scope functions are not allowed:

| Global (NOT allowed)  | Scoped Alternative                    |
| --------------------- | ------------------------------------- |
| `nowDateTime()`       | `new GlideDateTime()`                 |
| `getXMLWait()`        | `c.server.get()` or REST API          |
| `gs.print()`          | `gs.info()`, `gs.error()`            |

### Widget Example

```typescript fluent
import "@servicenow/sdk/global";
import { SPWidget } from "@servicenow/sdk/core";

export const myWidget = SPWidget({
  $id: Now.ID["my_widget"],
  name: "My Widget",
  htmlTemplate: Now.include("./template.html"),
  clientScript: Now.include("./client_script.js"),
  serverScript: Now.include("./server_script.js"),
  customCss: Now.include("./styles.css"),
  optionSchema: [
    {
      name: "table",
      label: "Table Name",
      type: "string",
      default_value: "incident",
      section: "Data"
    },
    {
      name: "max_records",
      label: "Max Records",
      type: "integer",
      default_value: "10",
      section: "Data"
    }
  ]
});
```

### Server Script Pattern

```javascript
// server_script.js
(function () {
  data.records = [];
  data.success = false;
  data.message = "";

  try {
    if (input && input.action === "submit") {
      var gr = new GlideRecord("incident");
      gr.initialize();
      gr.short_description = input.short_description;
      var id = gr.insert();
      data.success = !!id;
      data.message = id ? gr.getValue("number") + " created." : "Insert failed.";
      return;
    }

    // Initial load
    var gr = new GlideRecord("incident");
    gr.addQuery("active", true);
    gr.orderByDesc("sys_created_on");
    gr.setLimit(options.max_records || 50);
    gr.query();
    while (gr.next()) {
      data.records.push({
        sys_id: gr.getUniqueValue(),
        number: gr.getValue("number"),
        short_description: gr.getValue("short_description"),
        state: gr.getDisplayValue("state")
      });
    }
  } catch (e) {
    gs.error("Widget error: " + e.message);
    data.message = "An error occurred.";
  }
})();
```

### Client Script Pattern

```javascript
// client_script.js
api.controller = function (spUtil) {
  var c = this;

  c.loading = false;
  c.submitting = false;
  c.data = c.data || {};

  c.submit = function () {
    c.submitting = true;
    c.server
      .get({ action: "submit", short_description: c.form.short_description })
      .then(function (r) {
        if (r.data.success) {
          c.data.successMsg = r.data.message;
          c.form = {};
        } else {
          c.data.errorMsg = r.data.message || "An error occurred.";
        }
        c.submitting = false;
      });
  };
};
```

### HTML Template Pattern

```html
<!-- template.html -->
<div class="panel panel-default sp-card">
  <div class="panel-heading sp-card-header">
    <h3 class="panel-title sp-card-title">My Incidents</h3>
  </div>
  <div class="panel-body sp-card-body">
    <div class="sp-alert sp-alert-success" ng-if="c.data.successMsg" role="alert">
      {{c.data.successMsg}}
    </div>
    <ul class="list-group">
      <li ng-repeat="record in c.data.records track by record.sys_id"
          class="list-group-item">
        <strong>{{record.number}}</strong> -- {{record.short_description}}
        <span class="sp-badge sp-badge-info pull-right">{{record.state}}</span>
      </li>
    </ul>
    <div class="sp-empty-state" ng-if="!c.data.records.length && !c.loading">
      <span class="glyphicon glyphicon-inbox sp-empty-icon"></span>
      <p class="sp-empty-body">No records found.</p>
    </div>
  </div>
</div>
```

---

## API Reference: Theme (sp_theme)

For the full property reference, see the `sptheme-api` topic.

### Menu Display Prerequisites

Navigation menus will NOT appear unless ALL THREE are set:

1. Portal has `theme` configured
2. Theme has `header` configured (an `sp_header_footer` record)
3. Portal has `mainMenu` configured

### CSS Variable Rules

- Only `--now-*` tokens exist as platform CSS custom properties. Never invent token names.
- Use `sp-rgb(--now-token, #hex) !default` when a verified `--now-*` token exists for the color.
- Use hex directly when no token exists. Never invent a token name.
- Always add `!default` to allow per-portal variable overrides.

### Navbar Color Alignment (Required When Overriding `$brand-primary`)

When `customCss` declares a custom `$brand-primary`, you MUST also set these 8 navbar variables:

```scss
$navbar-inverse-bg:                  $brand-primary !default;
$navbar-inverse-border:              darken($brand-primary, 6.5%) !default;
$navbar-inverse-link-color:          $btn-primary-color !default;
$navbar-inverse-link-hover-color:    $btn-primary-color !default;
$navbar-inverse-link-active-color:   $btn-primary-color !default;
$navbar-inverse-brand-color:         $btn-primary-color !default;
$navbar-inverse-toggle-icon-bar-bg:  $btn-primary-color !default;
$navbar-inverse-toggle-border-color: darken($brand-primary, 10%) !default;
```

Skip this when using an OOTB theme or when `$brand-primary` is not being overridden.

### Theme Guidelines

- Always check for an existing theme first -- query the portal's `theme` field before any theme work
- Never create a new theme when one already exists on the portal
- Always reuse existing headers -- only create custom when explicitly requested
- Header is mandatory for menu display

### OOTB Themes

| Name                  | Sys ID                             | Description                         |
| --------------------- | ---------------------------------- | ----------------------------------- |
| Coral                 | `281507c44317d210ca4c1f425db8f2fd` | Coral color-scheme branding         |
| La Jolla              | `a7a6e78277002300a6e592718a10617a` | Modern flat design                  |
| Stock                 | `79315153cb33310000f8d856634c9c4b` | Default baseline theme              |
| Stock - High Contrast | `f84873986711320023c82e08f585ef6a` | Accessibility-compliant             |
| EC Theme              | `9b6f06d71bb8f85047582171604bcb9c` | Employee Center default             |
| Portal Next Experience| `f548bd34845a1110f87767389929c667` | Next-gen portal (Polaris)           |

When no custom theme is needed, prefer Coral first, then La Jolla. Always verify existence on instance before referencing.

### Theme Example

```typescript fluent
import "@servicenow/sdk/global";
import { SPTheme } from "@servicenow/sdk/core";

export const brandedTheme = SPTheme({
  $id: Now.ID["branded_theme"],
  name: "Branded Theme",
  customCss: `
    $brand-primary:      sp-rgb(--now-color--primary-1, #0047ab) !default;
    $brand-primary-dark: darken(#0047ab, 12%) !default;
    $link-color:         sp-rgb(--now-color--primary-1, #0047ab) !default;
    $body-bg:            sp-rgb(--now-color_background--secondary, #f4f5f7) !default;

    $navbar-inverse-bg:                  $brand-primary !default;
    $navbar-inverse-border:              darken($brand-primary, 6.5%) !default;
    $navbar-inverse-link-color:          $btn-primary-color !default;
    $navbar-inverse-link-hover-color:    $btn-primary-color !default;
    $navbar-inverse-link-active-color:   $btn-primary-color !default;
    $navbar-inverse-brand-color:         $btn-primary-color !default;
    $navbar-inverse-toggle-icon-bar-bg:  $btn-primary-color !default;
    $navbar-inverse-toggle-border-color: darken($brand-primary, 10%) !default;
  `,
  header: "bf5ec2f2cb10120000f8d856634c9c0c",
  footer: "feb4f763df121200ba13a4836bf26320",
  logo: Now.attach("logo.png"),
  logoAltText: "Company Logo"
});
```
