Landing Page Functions
A landing page is a promotion-owned page whose body is built entirely from page-builder widgets. It lives in the landing_page table, is served at the root-level friendly URL /<url_key> (mapped to the internal landingPageView route), and its content is a set of widget_placement rows scoped by the page's URN.
Import
import {
createLandingPage,
updateLandingPage,
deleteLandingPage,
duplicateLandingPage,
getLandingPagesBaseQuery,
syncLandingPageUrlRewrite,
deleteLandingPageUrlRewrite
} from '@evershop/evershop/promotion/services';
import type { LandingPageData } from '@evershop/evershop/promotion/services';
The data shape
interface LandingPageData {
name: string;
url_key: string;
status?: boolean | number | string;
description?: string | null;
meta_title?: string | null;
meta_description?: string | null;
publish_start?: string | null;
publish_end?: string | null;
}
| Field | Notes |
|---|---|
name | Required on create. Minimum length 1. |
url_key | Required on create. Must match ^[a-z0-9]+(?:-[a-z0-9]+)*$ — lowercase, digits, single hyphens. Unique across the table, and checked for collisions against other URL owners. |
status | Accepts true, false, 0, 1, '0', '1'. Normalized to a boolean before the write. |
publish_start / publish_end | Timestamps bounding the publish window. An empty string is normalized to null (a cleared datetime field posts '', which TIMESTAMPTZ rejects). null bounds mean open-ended. |
Every write runs inside its own transaction: it opens a connection, startTransaction, does the work, and commits — or rolls back and rethrows.
createLandingPage
createLandingPage(data: LandingPageData, context: object): Promise<LandingPage>
Parameters
| Parameter | Type | Description |
|---|---|---|
data | LandingPageData | The page. name and url_key are required here. |
context | object | Hook context, bound as this inside hook callbacks. Pass {} when you have nothing to add. |
Return Value
The inserted landing_page row.
What it does
- Runs the inbound data through the
landingPageDataBeforeCreateregistry value, then normalizes it. - Validates against the landing-page JSON schema with
nameandurl_keyrequired. - Asserts the
url_keyis not already taken by another URL owner. - Inserts the row.
- Writes the
url_rewriterow mapping/<url_key>→/landing/<url_key>.
Throws
| Condition | Message |
|---|---|
context is a non-object truthy value | Context must be an object |
| Schema validation fails | The first AJV error message |
url_key collides with an existing URL | Raised by the URL-key availability check |
Example
import { createLandingPage } from '@evershop/evershop/promotion/services';
const page = await createLandingPage(
{
name: 'Black Friday 2026',
url_key: 'black-friday-2026',
status: true,
meta_title: 'Black Friday — up to 50% off',
publish_start: '2026-11-25T00:00:00Z',
publish_end: '2026-12-02T00:00:00Z'
},
{}
);
console.log(page.uuid);
Hooks
createLandingPage (whole call) and insertLandingPageData (just the insert). Helpers: hookBeforeCreateLandingPage, hookAfterCreateLandingPage, hookBeforeInsertLandingPageData, hookAfterInsertLandingPageData.
updateLandingPage
updateLandingPage(uuid: string, data: Partial<LandingPageData>, context: object): Promise<LandingPage>
Patch a landing page. Every field is optional — an update with no changed columns is tolerated rather than throwing.
What it does
- Runs the data through
landingPageDataBeforeUpdate, normalizes and validates it. - Loads the current row.
- If
url_keychanged, asserts the new slug is free. - Updates the row.
- Re-syncs the
url_rewriteentry to the (possibly new)url_key. - On a rename, records a 302 redirect
/<oldKey>→/<newKey>, keyed by the page's URN, so existing links keep working.
Throws
| Condition | Message |
|---|---|
| No such page | Requested landing page not found |
context is a non-object truthy value | Context must be an object |
| Schema validation fails | The first AJV error message |
The new url_key is taken | Raised by the URL-key availability check |
Example
import { updateLandingPage } from '@evershop/evershop/promotion/services';
// Renaming keeps the old URL alive as a 302.
await updateLandingPage(
pageUuid,
{ url_key: 'black-friday', status: true },
{}
);
Hooks
updateLandingPage and updateLandingPageData, with the four matching helpers.
deleteLandingPage
deleteLandingPage(uuid: string, context: object): Promise<LandingPage>
Delete a landing page and everything hanging off it. Returns the row as it was before deletion.
What it does, in one transaction
- Purges historical redirect aliases for the page's URN, so old URLs stop 302ing.
- Removes the
url_rewriterow, so/<url_key>stops resolving. - Deletes the page body — every
widget_placementrow whoseentity_urnis this page. - Deletes every
widget_instancethat was placed only on this page (an instance still placed elsewhere survives). Raw SQL; cms widget hooks do not fire. - Deletes the
landing_pagerow.
widget_placement.entity_urn is a plain varchar with no foreign key to landing_page. Dropping the entity row alone would orphan every placement, which is why step 3 is explicit. If you write your own landing-page teardown, you must delete placements by URN yourself.
Throws
Invalid landing page id when no row matches the UUID, plus Context must be an object.
Hooks
deleteLandingPage and deleteLandingPageData.
duplicateLandingPage
duplicateLandingPage(uuid: string, context: object): Promise<LandingPage>
Deep-clone a landing page. Returns the new row.
What the copy looks like
| Field | Value on the copy |
|---|---|
status | false — the copy is always an unpublished draft |
name | <source name> (copy) |
url_key | <source key>-copy, or -copy-2, -copy-3, … until free (url_key is UNIQUE) |
description, meta_title, meta_description, publish_start, publish_end | Copied verbatim |
Deep clone, not shallow
Widget settings live on widget_instance, not on widget_placement. A placement-only copy would leave both pages pointing at the same instances, so editing the copy would silently mutate the original. duplicateLandingPage therefore clones each referenced widget_instance and repoints the cloned placements at the new instances and the new page URN.
Throws
Invalid landing page id when the source does not exist, plus Context must be an object.
Example
import { duplicateLandingPage } from '@evershop/evershop/promotion/services';
const draft = await duplicateLandingPage(pageUuid, {});
// draft.status === false, draft.url_key === 'black-friday-copy'
Hooks
duplicateLandingPageData, with hookBeforeDuplicateLandingPageData / hookAfterDuplicateLandingPageData.
getLandingPagesBaseQuery
getLandingPagesBaseQuery(): SelectQuery
A fresh select().from('landing_page') query. This is the base every landing-page listing builds on — GraphQL collections, admin grids, your own filters. Each call returns a new query object, so it is safe to mutate.
import { getLandingPagesBaseQuery } from '@evershop/evershop/promotion/services';
import { pool } from '@evershop/evershop/lib/postgres';
const query = getLandingPagesBaseQuery();
query.where('status', '=', true);
query.orderBy('landing_page_id', 'DESC');
const rows = await query.execute(pool);
syncLandingPageUrlRewrite
syncLandingPageUrlRewrite(
connection: PoolClient,
landingPage: { uuid: string; url_key: string }
): Promise<void>
Keep a landing page's url_rewrite row in sync with its current url_key. Writes request_path: '/<url_key>' → target_path: '/landing/<url_key>', keyed on entity_uuid so a rename overwrites the single row in place.
The CRUD services above already call it; you need it only when you write landing_page rows directly — a data migration, an importer.
| Parameter | Type | Description |
|---|---|---|
connection | PoolClient | Required. There is no default — pass the transaction client doing the write. |
landingPage | { uuid, url_key } | The page whose rewrite should be written. |
The companion deleteLandingPageUrlRewrite(connection, uuid) removes the row.
import { syncLandingPageUrlRewrite } from '@evershop/evershop/promotion/services';
await syncLandingPageUrlRewrite(connection, {
uuid: page.uuid,
url_key: page.url_key
});
replaceHomepage
replaceHomepage(uuid: string, context: { userId: number; routeId?: string; expectedFingerprints?: { homepage: string; landingPage: string } }): Promise<ReplaceHomepageResult>
preflightReplaceHomepage(uuid: string, userId: number): Promise<PreflightResult>
The service behind Replace homepage with this page. preflightReplaceHomepage runs the read-only checks the dialog shows; replaceHomepage performs the replacement in one transaction:
- Takes
pg_try_advisory_xact_lock(another run in progress →REPLACE_IN_PROGRESS), locks the landing page and the homepage placements, and compares the fingerprints from preflight (HOMEPAGE_CHANGEDon mismatch). - Refuses when an active or upcoming rollout plan touches the homepage (
HOMEPAGE_ROLLOUT_ACTIVE); cancels ended plans that touched it (plan rows only, changesets kept). - Discards the unpublished page-builder operations that touch the homepage in every open changeset; the changeset rows are never deleted.
- Creates a disabled backup landing page (name
Homepage backup <yyyy-LL-dd HH:mm>, url_keyhomepage-backup-…) through the create pipeline, copies the homepage instances and placements into it with fresh uuids, removes the homepage placements, and deletes originals left with no placement. - Clones the landing page's body onto route
homepage(landing_page_content→content, container children re-pointed), then re-checks and commits.
"Touching the homepage" is decided by the operation's target (placement uuid, instance uuid, payload route), not only by the route stamped on it.
Throws
ReplaceHomepageError with code ∈ LANDING_PAGE_NOT_FOUND (404), HOMEPAGE_ROLLOUT_ACTIVE, HOMEPAGE_CHANGED, HOMEPAGE_ENTITY_SCOPED, REPLACE_IN_PROGRESS (409) and, for HOMEPAGE_ROLLOUT_ACTIVE, a rolloutPlans array.
Hooks
replaceHomepage (outer, no connection), and the inner, transaction-joined createHomepageBackup, snapshotHomepage and cloneWidgetBody. Helper pairs: hookBefore/AfterReplaceHomepage, hookBefore/AfterCreateHomepageBackup, hookBefore/AfterSnapshotHomepage.
cloneWidgetBody
cloneWidgetBody(conn: PoolClient, opts: {
from: { route: string | null; entityUrn: string | null };
to: { route: string | null; entityUrn: string | null };
areaMap?: Record<string, string>;
excludeAreas?: string[];
theme: 'active' | 'preserve';
activeTheme?: string | null;
}): Promise<{ clonedInstances: number; clonedPlacements: number; instanceUuidMap: Map<string, string> }>
Copies every placement in one scope, together with the instances behind them, into another scope with fresh uuids. Container-child areas (columnsContainer_<parent uuid>_col_<i>) are re-pointed at the copied parent. theme: 'preserve' copies every theme bucket and keeps each row's theme (what duplicateLandingPage uses); theme: 'active' copies only the active bucket and stamps it. Set-based (three statements regardless of body size). Writes both tables directly — cms widget hooks and processors do not fire.
findFreeUrlKey
findFreeUrlKey(isTaken: (candidate: string) => Promise<boolean>, base: string, suffix?: string): Promise<string>
Returns base (or <base>-<suffix>) if free, else appends -2, -3, … Used by Duplicate (-copy) and by the homepage backup.
buildBackupIdentity
buildBackupIdentity(now: Date, timezone: string | null, replacedByName: string): { name: string; urlKeyBase: string; description: string; zone: string }
Pure. Formats the backup's name and url_key base in the store timezone, falling back to UTC when the zone is empty or invalid. HOMEPAGE_BACKUP_URL_KEY_PREFIX (homepage-backup-) is the contract that identifies backups.
See Also
- Landing Page API — The REST endpoints behind the admin UI
- Page Builder — How the page body is composed from widgets
- URL Redirects — The rename-redirect machinery
- URN —
PromotionUrn.landingPage(uuid), the key placements are scoped by