Skip to main content

Widget Link Fields and Preview Components

Two parts of widget authoring have no equivalent in older EverShop versions and are easy to get wrong: link fields, which store a URN instead of a URL, and the previewComponent, which registerWidget now refuses to register without.

A widget CTA used to store whatever URL the merchant typed. Rename the category, change a page's slug, and every widget pointing at it broke silently — nothing in the system knew those strings referred to an entity.

Links are now stored as URNs and resolved to the entity's current URL at request time:

urn:evershop:<service>:<type>:<uuid>
urn:evershop:catalog:category:9c1a7f42-06d3-4c8b-a1f9-3e5d7c2b8a10
urn:evershop:cms:page:1b7e2d90-45aa-4f31-8c02-6d9f1e3a7b55
urn:evershop:promotion:landing_page:c81e7f2a-9b31-4d0c-8b6e-1a5f7c3d9042

A plain URL or relative path is still a valid stored value and passes straight through — the "Custom URL" tab of the link picker produces exactly that.

Types registered out of the box

URNs are validated against a registry, so an unregistered (service, type) pair cannot even be built. The core set lives in lib/urn/index.ts:

URNResolves viaRegistered by
catalog:producturl_rewrite (entity_type = 'product'), falling back to buildUrl('productView', { uuid })Built in
catalog:categoryurl_rewrite (entity_type = 'category'), falling back to buildUrl('categoryView', { uuid })Built in
cms:pageurl_rewrite (entity_type = 'cms_page'), localized; null when the page has no rewriteBuilt in
blog:post, blog:category, blog:tagurl_rewrite, falling back to the internal routemodules/blog/bootstrap.ts
promotion:landing_pagelanding_page.url_key directly (that table keeps the slug inline), localizedmodules/promotion bootstrap

There is deliberately no collection loader. Collections are non-navigable groupings with no public page; widgets that display a collection reference it by code in their settings instead.

Use LinkPicker in your setting component. It renders one tab per linkable entity type plus a freeform tab, and hands back a URN for entity picks and a plain string for custom URLs:

import { LinkPicker } from '@components/common/page-builder/pickers/LinkPicker.js';

<LinkPicker
value={link}
onChange={({ url }) => setValue('settings.link', url)}
initialKind="custom"
/>
PropMeaning
valueCurrent stored value — a URN or a plain URL.
onChangeReceives { url, kind, label? }. Persist url; kind is an admin-only display hint.
initialKindTab to open on. Defaults to custom. Ignored when value is a URN, because the tab is derivable from it.
allowedKindsRestrict the visible tabs, for a CTA that should only ever point at one kind of thing.

Available kinds: page, landingPage, category, product, blogPost, blogCategory, blogTag, custom. For a full call-to-action (label + URL + new-tab + style) use CtaField, which wraps LinkPicker.

Resolution happens in your widget's GraphQL resolver, not in the React component. The GraphQL middleware builds a set of per-request loaders and puts them on the context as linkLoaders; your resolver passes each stored value through resolveLink:

import { resolveLink } from '@evershop/evershop/lib/widget/linkResolver';

export default {
Query: {
async myHeroWidget(_, { link, ctaUrl }, { linkLoaders }) {
const [resolved, resolvedCta] = await Promise.all([
resolveLink(link, linkLoaders),
resolveLink(ctaUrl, linkLoaders)
]);
return { link: resolved, ctaUrl: resolvedCta };
}
}
};

resolveLink(value, loaders) returns:

InputOutput
Empty / null / undefinednull
Plain URL or relative path with a safe schemeThe same string, unchanged
Plain URL with an unsafe scheme (javascript:, data:, vbscript:)null — the anchor must be suppressed
A URN with a registered loaderThe entity's current URL, or null when it cannot be found
A URN with no registered loadernull

The unsafe-scheme filter (isSafeUrl) is the security boundary — it strips ASCII control characters before testing the scheme, so a smuggled java\tscript: cannot slip through. Allowed schemes are http, https, mailto and tel; relative paths, anchors and query-only links are always safe.

Because a missing entity yields null, storefront components should render the element without an href (or not at all) rather than assuming a string:

{url ? <a href={url}>{label}</a> : <span>{label}</span>}

Batching

createLinkLoaders(pool) is called once per request by the GraphQL middleware. Each loader it produces is a small DataLoader-style batcher: .load(id) queues the id, and a queueMicrotask flush resolves the whole queue with one query. Results are cached per request, so the same entity referenced by twenty widgets costs one lookup.

The practical effect: a page with a 30-link mega-menu across products, categories and pages issues one query per kind, not thirty.

Two registrations, both from your module's bootstrap.ts — the value registry is locked once bootstrap completes, so a later call throws.

import { registerUrnSchema } from '@evershop/evershop/lib/urn';
import {
registerLinkLoader,
linkLoaderFromBatch
} from '@evershop/evershop/lib/widget/linkResolver';
import { select } from '@evershop/evershop/lib/postgres/query';

export default (): void => {
registerUrnSchema({
service: 'academy',
type: 'course',
description: 'Academy course'
});

registerLinkLoader(
'academy',
'course',
linkLoaderFromBatch(async (uuids, pool) => {
if (uuids.length === 0) {
return [];
}
const rows = await select('uuid', 'slug')
.from('academy_course')
.where('uuid', 'IN', [...uuids])
.execute(pool);
const map = new Map(rows.map((r: any) => [r.uuid, `/course/${r.slug}`]));
return uuids.map((u) => map.get(u) ?? null);
})
);
};
FunctionSignatureNotes
registerUrnSchema({ service, type, description }) => voidThrows if the pair is already registered. Without it, UrnService.build and parse both throw and the page builder rejects the value.
registerLinkLoader(service, type, factory) => voidAdds a processor to the linkLoaderFactories registry key. Bootstrap only.
linkLoaderFromBatch(batchFn) => LinkLoaderFactoryWraps a batch function so you never write queueing code yourself.

Your batch function receives (ids, pool) and must return an array of the same length, in the same order, using null for ids it could not resolve. Returning a shorter array silently maps the tail to null.

Two real implementations to copy from:

  • modules/blog/bootstrap.ts — one shared factory parameterized by (entityType, routeId), registered three times for post, category and tag. Reads url_rewrite and falls back to buildUrl(routeId, { uuid }).
  • modules/promotion/services/landingPage/registerLandingPageLinkLoader.ts — reads url_key straight off landing_page and wraps it in localizeUrl('/' + url_key) so non-default storefront locales get their prefix.

If your entity is reachable at a friendly URL, prefer reading url_rewrite over reconstructing the path — that is what keeps the link correct after a rename.

The failure mode: a throwing loader fails silently

A loader that throws is swallowed by the batcher

The batcher's flush wraps your batch function in a try/catch and, on any error, resolves every in-flight id to null:

try {
const values = await batchFn(ids, pool);
batch.forEach(({ resolve }, i) => resolve(values[i] ?? null));
} catch {
batch.forEach(({ resolve }) => resolve(null));
}

That is deliberate — one broken loader must not 500 the whole storefront page. But it means a bad query produces no error anywhere: no exception, no 500, nothing in the response. Every link of that kind simply renders as an unclickable anchor with no href, and the page otherwise looks perfect.

This has bitten core. The cms:page loader originally did select('uuid', 'url_key').from('cms_page') — but url_key lives on cms_page_description, not cms_page. The query threw column "url_key" does not exist on every request, the batcher swallowed it, and every CMS page link in every menu became dead. Nothing surfaced it until someone clicked one.

How to avoid repeating it:

  • Test the loader directly. Call the batch function against a real connection with a known uuid and assert it returns the URL. The guard test for the core loaders is lib/widget/tests/unit/linkResolver.test.ts.
  • When links render without an href, suspect the loader first. The symptom is indistinguishable from "entity not found", so check your table and column names before anything else.
  • Log inside your batch function while developing. The catch is in the batcher, not in your code, so a try/catch of your own around the query body is the only place you can observe the error.

The mandatory previewComponent

Every widget type registers three components. The third one, previewComponent, is what the page-builder palette shows in its hover card, and registerWidget throws without it:

import path from 'path';
import { registerWidget } from '@evershop/evershop/lib/widget';

registerWidget({
type: 'greeting_widget',
name: 'Greeting Widget',
category: 'content',
settingComponent: path.resolve(
import.meta.dirname,
'components/widgets/GreetingWidgetSetting.js'
),
component: path.resolve(
import.meta.dirname,
'components/widgets/GreetingWidget.js'
),
previewComponent: path.resolve(
import.meta.dirname,
'components/widgets/GreetingWidgetPreview.js'
),
enabled: true,
defaultSettings: { text: 'Hello!' }
});

The rules lib/widget/widgetManager.ts enforces on the path are the same as for the other two components, and each violation is a thrown error, not a warning:

RuleWhy
The property must be presentAn empty or missing path fails the resolvable-path check and throws.
The file must exist and be a .js filePaths point at compiled output, not your .tsx source. Author GreetingWidgetPreview.tsx, register GreetingWidgetPreview.js.
The basename must start with an uppercase letterThe master-component convention — a lowercase filename is treated as middleware everywhere else in EverShop.

Writing one

The preview component takes no props and receives no GraphQL data, no widget settings and no page context. It renders a self-contained stylized mock — rectangles, lines, placeholder blocks — that tells a merchandiser at a glance what the widget looks like:

import React from 'react';

export default function GreetingWidgetPreview(): React.ReactElement {
return (
<div style={{ padding: 16, background: '#fff' }}>
<div style={{ height: 10, width: '55%', borderRadius: 3, background: '#111' }} />
<div style={{ height: 6, width: '80%', marginTop: 8, borderRadius: 3, background: '#d4d4d8' }} />
<div style={{ height: 6, width: '70%', marginTop: 5, borderRadius: 3, background: '#d4d4d8' }} />
</div>
);
}

Any *Preview.tsx under modules/cms/components/ is a working reference.

How it is bundled

The preview is compiled into the admin bundle under the wildcard-area key admin_widget_preview_<type> (by lib/webpack/loaders/AreaLoader.js in development and bin/lib/buildEntry.js in production builds), and looked up at runtime by WidgetPreviewCard. You never import it yourself — registering the path is the whole wiring.

Because it lands in the admin bundle, anything that only exists on the storefront (a storefront-only context, a browser API guarded elsewhere) will break it. Keep it pure and static.

Common pitfalls

  • Calling registerLinkLoader outside bootstrap.ts. The value registry locks after bootstrap; a later call throws.
  • Registering a loader without registering the URN schema. UrnService.build and parse both throw for an unknown (service, type), and addChangesetOperation rejects placements carrying it.
  • A batch function that returns a differently ordered or shorter array. Results are matched by index; anything missing becomes null.
  • Expecting an error when a loader breaks. You will not get one — every link of that kind just loses its href.
  • Resolving in the React component instead of the resolver. linkLoaders lives on the GraphQL context. A component has no access to it.
  • Assuming resolveLink always returns a string. It returns null for missing entities and for unsafe schemes. Handle it.
  • Registering previewComponent as a .tsx path. It must point at compiled .js.
  • A lowercase preview filename. registerWidget rejects it.
  • Giving the preview component props or data dependencies. It is rendered prop-less in the admin palette, with nothing to feed it.

See also



Support us


EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.