---
title: custom.js & $functions
slug: experience/customjs-and-dollarfunctions
docTags: 
createdAt: 2026-05-15T13:19:34.073Z
---

## Overview

Purple Experience provides two JavaScript extension files:

| File               | Path                                         | When it runs          | What it can set                                                                        |
| ------------------ | -------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- |
| `custom.server.js` | `storefront/assets/scripts/custom.server.js` | Server (SSR) + client | `window.$functions` only                                                               |
| `custom.js`        | `storefront/assets/scripts/custom.js`        | Browser only          | `window.storefrontHooks`, `window.isIssueLocked`, `window.calculateStorefrontUserTags` |

`$functions`**&#x20;must be defined in&#x20;**`custom.server.js`, not `custom.js`. This is because `$functions` are used during config resolution, which happens on the server during SSR. `custom.server.js` runs a strict sandbox — any attempt to access or set `window.*` properties other than `$functions` is blocked.

`custom.js` is browser-only and is the correct place for lifecycle hooks and lock-state overrides.

## When to use this

Use `custom.server.js` when you need to:

- Expose helper functions that `views.json` can call during config resolution (`$functions`)

Use `custom.js` when you need to:

- Track page views in an external analytics system (`onNavigationEnd`)
- Run custom logic after content loads (`onContentLoaded`)
- Intercept or customise purchase flows (`beforeContentPurchase`, `beforeSubscriptionPurchase`)
- Initialise ad networks (`onAdInit`)
- Customise lock state display for issues (`isIssueLocked`)
- Compute and set user tags based on purchase/subscription data (`calculateStorefrontUserTags`)

## Basic Example

`custom.server.js` — helper functions for config resolution:

```javascript
window.$functions = {
    getIssueYear: (issue) => (issue ? new Date(issue.publicationDate).getFullYear().toString() : ''),
    getOptionalSourceIssueId: (issue) => (issue && issue.sourceIssue ? issue.sourceIssue.id : ''),
};
```

`custom.js` — lifecycle hooks (browser only):

```javascript
window.storefrontHooks = {
    onNavigationEnd: ({ event, viewContext }) => {
        analytics.track('page_view', {
            path: event.urlAfterRedirects,
            platform: viewContext.platform,
        });
    },
};
```

In `views.json`:

```json
{
    "type": "html",
    "tag": "span",
    "text": "$functions.getIssueYear($context.issue)"
}
```

## Configuration

### `window.storefrontHooks` — lifecycle callbacks

Register callbacks on `window.storefrontHooks` to react to framework lifecycle events. All hooks are optional — define only the ones you need.

For the full hook reference (parameters, return values, and examples for each hook), see [Storefront Hooks Documentation](docId\:aE_kB6wWnKnGU9DhR43Hv)&#x20;

Available hooks at a glance:

| Hook                         | When it fires                        |
| ---------------------------- | ------------------------------------ |
| `onPurpleServiceInit`        | Purple Service is ready              |
| `onNavigationEnd`            | After Angular NavigationEnd          |
| `onNavigationStable`         | App stable after navigation          |
| `onContentLoaded`            | Catalog content fully loaded         |
| `beforeContentPurchase`      | Before a content purchase            |
| `beforeSubscriptionPurchase` | Before a subscription purchase       |
| `entitlementChanged`         | Login / logout / subscription change |
| `subscriptionsChanged`       | User subscriptions updated           |
| `onAdInit`                   | Ad network initialisation            |
| `beforeRenderAd`             | Before an ad slot renders            |
| `isAdRefreshPaused`          | Ad refresh pause check               |
| `isAdDisabled`               | Per-slot disable check               |
| `getIntersectionMargin`      | Lazy-load trigger distance           |

***

### `window.$functions` — callable from views.json

Register functions on `window.$functions`. They are called synchronously during config resolution whenever `$functions.myFn(...)` appears in `views.json`.

```javascript
window.$functions = {
    // Return a formatted year string from an issue object
    getIssueYear: (issue) => (issue ? new Date(issue.publicationDate).getFullYear().toString() : ''),

    // Return empty string if issue has no sourceIssue
    getOptionalSourceIssueId: (issue) => (issue && issue.sourceIssue ? issue.sourceIssue.id : ''),

    // Functions can call other $functions or receive $context parts
    formatTitle: (title, suffix) => (title ? `${title} — ${suffix}` : suffix),
};
```

Calling from `views.json`:

```json
{
    "type": "html",
    "tag": "span",
    "text": "$functions.formatTitle($context.issue.name, $context.publication.name)"
}
```

- Functions receive the already-resolved argument values — `$context.*` substitution happens before the function is called
- Functions can be nested: `$functions.outer($functions.inner($context.attr))`
- Return `undefined` or empty string to produce no output
- Functions must be synchronous — async functions are not supported in config resolution

***

### `window.isIssueLocked(issue, purpleService)` — custom lock logic

Override this async function to customise when an issue is shown as locked. The default always returns `false` (no additional lock conditions). This is called only after the mandatory check (`purchasable: true AND purchased: false`) returns `false`.

```javascript
window.isIssueLocked = async function (issue, purpleService) {
    // Lock issues that have a custom property set
    if (issue.properties && issue.properties['requires-premium'] === 'true') {
        const isPremium = await purpleService.hasSubscription('premium-plan-id');
        return !isPremium;
    }
    return false;
};
```

| Parameter       | Description                                                                |
| --------------- | -------------------------------------------------------------------------- |
| `issue`         | The `CatalogIssue` object being evaluated                                  |
| `purpleService` | Purple Service instance — provides access to entitlement and purchase APIs |

***

### `window.calculateStorefrontUserTags(subs, issues, pubProds)` — custom user tags

When defined, this function is called after the user's purchase data is loaded. It computes a set of boolean tags that are then accessible as `$context.userTags.<tag>`.

**Important**: The function must always return ALL possible tags (even the ones that are `false`), because the tracking service uses the full set to remove tags that are no longer active.

```javascript
window.calculateStorefrontUserTags = (subs, issues, pubProds) => {
    const hasActiveSub = subs.some((s) => s.purchased);
    const hasBoughtIssue = issues.length > 0;

    return {
        isSubscriber: hasActiveSub,
        hasPurchasedIssue: hasBoughtIssue,
        isPremiumUser: hasActiveSub && hasBoughtIssue,
    };
};
```

Access in `views.json`:

```json
{
    "type": "section",
    "condition": {
        "value": "$context.userTags.isSubscriber",
        "operation": "EQUALS",
        "compareValue": "true"
    },
    "content": [{ "type": "html", "tag": "p", "text": "Subscriber content" }]
}
```

| Parameter  | Type                  | Description                                          |
| ---------- | --------------------- | ---------------------------------------------------- |
| `subs`     | AppSubscription\[]    | All subscription plans (purchased and not purchased) |
| `issues`   | CatalogIssue\[]       | All purchased issues                                 |
| `pubProds` | PublicationProduct\[] | All purchased publication products                   |

## Testing Notes / Edge Cases

- `$functions`**&#x20;must be synchronous**: If a function returns a Promise, the promise object is used as the value — it is not awaited. This will produce broken output. Keep `$functions` purely synchronous.
- `calculateStorefrontUserTags`**&#x20;must return all tags**: If a tag key is missing from the return value, the previous value for that tag remains in context indefinitely. Always return every tag with an explicit `true` or `false`.
- `isIssueLocked`**&#x20;is called per issue, per render**: Keep it lightweight. Avoid heavy API calls inside it; cache results if needed.
- **Hook error handling**: Experience wraps hooks in try-catch. If a hook throws, an error is logged but the app continues. Never rely on hooks throwing to cancel flows — return the appropriate result object instead. See Storefront Hooks Documentation for hook-specific notes.

## Related Topics

- [Context System](docId\:C0z2LRMsCiCnrsJymKvUb)&#x20;
- [Data Sources](docId\:fuMBUaBWeON_OTO5wfevf)&#x20;
- TODO: insert link to Dynamic URL Resolving
