Skip to content

Writing

Marketplace App Round Two — Shipping Beyond Our SEO Auditor

Muhammad Ali Khan · 02 Nov 2026 · 07 Mins read

Marketplace App Round Two — Shipping Beyond Our SEO Auditor

The SEO auditor I shipped in October 2025 did the job I built it for. It sits in the page builder as a context panel, fetches the published HTML, and reports missing alt text, titles, and heading problems without making an author open another product. Authors used it. The next complaint was not about meta tags. It was about a single-line text field named Icon that had accumulated eighteen spellings of the same glyph, plus three JSON blobs a developer had pasted in as a workaround. The auditor could not see that. It reads the rendered page. The bad value was already valid HTML by the time it got there, a span with a class the design system does not have.

Round two is a different extension point. I wanted the authoring UI to store a value the Next.js component can trust, and I wanted Sitecore to hold that value as a field rather than as a finding in a side panel. The Marketplace custom field is the point Sitecore documents for exactly this shape. This post is what I built after the auditor, and what I will not pretend the auditor already was.


Pick the extension point before you pick the stack

Sitecore's Marketplace docs group the places an app can appear. A standalone app opens from the Cloud Portal. A full-screen app hangs off the site portfolio for work that is about the environment rather than one page. The page context panel, which is where the auditor lives, is for tools that look at the page the author has open. Dashboard widgets are for site-wide numbers. The custom field extends a data template. The author clicks Open app, your UI runs in a modal, and the value you set is stored on the item as raw text or JSON.

Those are different products that happen to share a portal. I kept the auditor on the context panel because its input is published HTML and its output is a list of findings. Findings are not a field value. I did not "upgrade" it into a custom field that stores the audit report on every page item. That would have written derived data into the template and gone stale on the next publish.

The governed link field is the opposite shape. The author must choose an icon name from a list we control, plus an accessible label, plus an optional internal path. The component needs that structure every time it renders. Sitecore does not validate the raw payload a custom field stores. The docs say so directly, and they make it the app developer's problem. I was glad to have the problem. A single-line text field had already proved what happens when validation lives only in a code review.


Registration, without repeating the auditor's walkthrough

The auditor's registration is still the right portal path, and I am not going to paste it again. Cloud Portal, App studio, a custom app, a deployment URL, extension points, then install into the organization. What changed for this app is the extension point and the template, not the fact that an app has a URL.

I enable the custom field extension point and set its route to the path the app actually serves, in our case /custom-field/governed-link. The route in the portal and the route in the app have to be the same string. I learned that by pointing the portal at / while the field UI lived on a nested route and staring at the wrong page inside the modal.

In the Content Editor I add a field on the template the rendering's datasource uses. The type is Marketplace Types, Plugin. The source is the Marketplace app id. After a reload, the field appears in the page builder: on the content tab for a content item, or in the page content panel for a page-level field. Authors with access to the app see Open app. Everyone else sees the raw stored text, which is a good reason to keep the JSON small and to grant the app to the author role instead of to developers only.

API access for this app is none. The field value is read and written through the Marketplace client, and the list of allowed icons is compiled into the app. I do not give a custom field the Authoring API so it can wander the tree. If a later version must look up an internal path against the site, that is a new permission review, not a checkbox I tick because it was on the screen.


The value the modal is allowed to save

@sitecore-marketplace-sdk/client is the package every Marketplace app initializes. In the custom field UI I read with client.getValue() and write with client.setValue(value, true), which is the call the custom-field guide shows, and then I call closeApp() so the author lands back on the field. I do not pretend the boolean is a permission check. I keep it because the documented sample passes it, and I read the type in the version of the SDK I installed before I invent a meaning for it. getValue() may return a string or an already-parsed object. I normalize both.

The contract is a TypeScript type, shared in spirit with the Next.js component even though the apps are separate repositories. I would put it in a package if both repos could consume it. Until then I duplicate it on purpose and add a test in each repo that pins the JSON.

const ICONS = ["arrow", "download", "external", "none"] as const;

type GovernedLink = {
  icon: (typeof ICONS)[number];
  label: string;
  href?: string;
};

export function parseGovernedLink(raw: unknown): GovernedLink {
  const value = typeof raw === "string" ? JSON.parse(raw) : raw;
  if (!value || typeof value !== "object") {
    throw new Error("Governed link field is empty or not an object.");
  }

  const candidate = value as Partial<GovernedLink>;
  if (!candidate.icon || !ICONS.includes(candidate.icon)) {
    throw new Error("Icon is not in the allowed set.");
  }
  if (!candidate.label?.trim()) {
    throw new Error("Accessible label is required.");
  }
  if (candidate.href && !candidate.href.startsWith("/")) {
    throw new Error("Href must be an internal path. Absolute URLs are out of this field.");
  }

  return {
    icon: candidate.icon,
    label: candidate.label.trim(),
    href: candidate.href,
  };
}

The save handler parses first and calls setValue(JSON.stringify(parsed), true) only when parsing returns. An author cannot store arroww anymore, and they cannot store https:// something the design system will render as a raw anchor with no governance. External URLs, when we need them, go through the link field Sitecore already models, where target and rel are explicit. I am not rebuilding the link manager inside a JSON blob.

href is optional because an icon-only status is a real datasource in our design system. The label is not optional, even when the icon is none, because the component prints the label as text. That rule came from the same accessibility list the auditor checks on the published page. The two apps meet at the HTML. They do not share a panel.


What the head does with the field

The page builder stores the JSON string on the item. Layout data delivers it to the rendering as a field value. The component parses with the same rules and renders a known icon component, never dangerouslySetInnerHTML, and never a class name taken from the string verbatim beyond the union above.

export function GovernedLinkView({ raw }: { raw: string | undefined }) {
  if (!raw) return null;
  try {
    const link = parseGovernedLink(raw);
    return (
      <a href={link.href ?? "#"} data-icon={link.icon}>
        {link.label}
      </a>
    );
  } catch {
    return null;
  }
}

A thrown parse on the published site becomes nothing, which I would rather see than a broken class. In editing, I want the opposite: the custom field modal already refused the bad value, so a parse failure in the head means the datasource was written by a script or an import that bypassed the modal. I log that in the authoring environment. I do not try to repair it in the component.

This is also the limit of an agent or an import. Agentic Studio can set a field if its tools allow it. It does not open my modal, so it does not run parseGovernedLink unless the flow validates before it writes. I put the allowed JSON in the rendering contract I already keep next to the component. A flow that writes {"icon":"sparkles"} will publish a field the component drops. That is a successful agent run and a blank link, and it is the same class of bug I described for unknown renderings.


What I left out of round two on purpose

I did not add a second context panel that re-implements the auditor with stricter rules. The auditor's repository is still the place for those rules, and other people can fork it. I did not add a dashboard widget for SEO scores. A widget is the right extension point when the input is an external metric for the whole site. We do not have that metric owned yet, and a widget full of the same three findings as the panel would be a second UI for one idea.

I did not store the audit JSON on the page item. Derived findings belong in the panel, computed from the published URL, so they cannot drift from the HTML. Stored authoring choices belong on the template, written through a custom field that rejects anything the component cannot render.

Public apps and custom apps follow the same field type. The difference that mattered to me is who is allowed to install and change the app. This one stays a custom app in our organization until the icon set is boring enough to be someone else's problem. The deployment URL moves from the localhost the studio asks for to the hosted origin when authors outside my machine need it. I do not leave http://localhost:3000 on an app I have installed for the editorial team. The auditor taught me that authors will click the panel on the day your laptop is closed.


The sequence I would use for the next field

  1. Write the type the React component can render, including the values it must reject.
  2. Choose the extension point from the shape of the data. A finding is a panel. A stored value is a custom field. An environment-wide tool is full screen.
  3. Register the app, bind the route, and add the Plugin field with the app id as its source.
  4. Read and write through getValue and setValue. Parse before you persist.
  5. Parse again in the head. Treat a bypassed write as a blank component plus a log, not as a second source of truth.

The auditor remains the app I point people to when they want a published-page check inside the editor. Round two is the app I point them to when the template itself is how the bad value gets into the page. Marketplace work in this ecosystem is a set of extension points. Shipping a second one taught me more than adding a fourth rule to the first.

Related notes