Page Builder live preview sends Link fields with `allowText` as empty (`link_type: "Any"`) to `/slice-simulator` even though the editor shows the field filled

Page Builder live preview sends Link fields with allowText as empty (link_type: "Any") to /slice-simulator even though the editor shows the field filled.

I'm running a multi-tenant SaaS on Cloudflare Workers (via OpenNext) where each tenant has their own Prismic repository. Slices are shared across all tenants via a sync-slices push.

One of my slices, hero_simple, has a cta_link field configured with allowText: true. When editing the slice in Page Builder for a tenant's repository, the live preview iframe pointed at my /slice-simulator route receives the field with link_type: "Any" and empty text — even though I filled in both URL and display text in the editor and the field appears filled.

All other field types in the same slice (RichText for heading and body, Select for background_theme) serialize correctly. Only Link with allowText is affected.

Preview URL configured in Prismic: https://<tenant>.nyvia.dk/slice-simulator

Slice-simulator route at app/slice-simulator/page.tsx:

import { SliceSimulator, SliceSimulatorParams, getSlices } from "@prismicio/next";
import { SliceZone } from "@prismicio/react";
import { components } from "../../slices";

export default async function SliceSimulatorPage({ searchParams }: SliceSimulatorParams) {
  const { state } = await searchParams;
  const slices = getSlices(state);
  return (
    <SliceSimulator>
      <SliceZone slices={slices} components={components} />
    </SliceSimulator>
  );
}

Field added via CLI:

prismic field add link cta_link --to-slice hero_simple --allow-text --allow-target-blank --label "Call-to-action"

Slice model excerpt in slices/HeroSimple/model.json:

"cta_link": {
  "type": "Link",
  "config": {
    "label": "Call-to-action",
    "allowTargetBlank": true,
    "allowText": true
  }
}

How I debugged this — I added raw output of slice.primary.cta_link at the top of the slice component to see exactly what the simulator receives:

const { cta_link } = slice.primary;

return (
  <div className="flex gap-5">
    <PrismicNextLink field={cta_link} linkResolver={linkResolver} />
    <a href={cta_link.link_type === "Web" && cta_link.url ? cta_link.url : ""}>
      {cta_link.text}
    </a>
    <p>{cta_link.link_type}</p>
    <p>{cta_link.link_type === "Web" && cta_link.url ? cta_link.url : ""}</p>
    <p>{cta_link.text}</p>
  </div>
);

In production (published document served via Prismic CDN), the rendered output is:

hej
hej
Web
https://www.youtube.com/
hej

In Page Builder live preview (same document, same field, filled with same values in the editor), the rendered output is:

<div class="flex gap-5">
  <a href=""></a>
  <a href=""></a>
  <p>Any</p>
  <p></p>
  <p></p>
</div>

The PrismicNextLink renders as an empty <a href="">. link_type is "Any", url is missing, text is empty — even though the same field returns the correct values from the production API.

Expected — the live preview /slice-simulator should receive the same field shape the production API returns:

{
  "link_type": "Web",
  "url": "https://www.youtube.com/",
  "text": "hej"
}

Actual — what simulator receives:

{
  "link_type": "Any"
}

Environment:

@prismicio/client 7.21.8
@prismicio/next 2.2.4
@prismicio/react 3.4.1
Next.js 16.2.10 (App Router)
Runtime: Cloudflare Workers via @opennextjs/cloudflare 1.20.1
Slice files managed via Prismic CLI (prismic slice, prismic field commands) — no Slice Machine

Additional context — because the Link field's text and url never reach the simulator, <PrismicNextLink> renders empty and CTA buttons visually disappear in live preview. Content editors see broken previews for any slice with a Link-with-text CTA, which makes the editing workflow confusing (they think their content is broken).

Workaround — splitting the field into a separate KeyText for label + plain Link (without allowText) for URL makes it work. KeyText serializes correctly through the postMessage → lz-string → simulator pipeline. But this defeats the purpose of allowText and forces a slice model refactor plus content re-entry for every tenant already onboarded.

Is this a known limitation of the Page Builder to slice-simulator state serialization, or should this work?

Hey @nyvia.dk, does this happen with a plain Web link without allowText, or only when allowText: true is on? And does it behave any differently with a document link vs a Web link?

Could you also log the raw state param (or the full slice.primary) at the top of your simulator page, before it hits the component, and share a screenshot? That'd show us exactly what's landing there vs what the API returns — and whether url/text are already missing at that point.

Your KeyText + plain Link workaround is a good fix in the meantime.

Reproduced — the trigger is an unrestricted Link field, not allowText

Setup: Next.js (App Router), @prismicio/client 7.21.8 / @prismicio/next 2.2.4. I logged the raw slice.primary at the top of /slice-simulator (before it reaches any component), then filled one Link field per configuration in the Page Builder.

Result — what the live-preview state actually sends

Field config (select = type restriction) allowText Value received by the simulator
select: "web" off / on { "link_type": "Web", "url": "…" (, "text": "…") } :white_check_mark:
select: "media" off / on { "link_type": "Media", "url": "…", … (, "text": "…") } :white_check_mark:
select: "document" off / on { "id": "…", "type": "broken_type", "isBroken": true (, "text": "…") }no link_type
no select (unrestricted) off / on { "link_type": "Any" } — everything stripped

Findings

  1. An unrestricted Link field (no select) is serialized as an empty { "link_type": "Any" } — both url and text are gone, so isFilled.link() returns false and the element disappears. This is independent of allowText (the plain and the allowText variant strip identically).
  2. Restricting the field (select: "web" or "media") makes it come through correctly (URL + display text intact).
  3. Document links (select: "document") arrive as { "type": "broken_type", "isBroken": true } with no link_type — a separate preview-resolution issue.

The published API returns all of these correctly — only the live-preview / slice-simulator state is affected. So the original "allowText" framing looks like a coincidence: the affected fields happened to be unrestricted Link fields (which commonly also have allowText enabled). The actual trigger is the missing type restriction.


Repro files (trimmed to the essentials)

1. Slice modelslices/SliceLinkTest/model.json

{
  "id": "slice_link_test",
  "name": "SliceLinkTest",
  "type": "SharedSlice",
  "variations": [
    {
      "id": "default",
      "name": "Default",
      "primary": {
        "plain_web":   { "type": "Link", "config": { "label": "Plain — web",           "select": "web",      "allowTargetBlank": true } },
        "plain_doc":   { "type": "Link", "config": { "label": "Plain — document",       "select": "document", "allowTargetBlank": true } },
        "text_web":    { "type": "Link", "config": { "label": "allowText — web",        "select": "web",      "allowTargetBlank": true, "allowText": true } },
        "text_doc":    { "type": "Link", "config": { "label": "allowText — document",   "select": "document", "allowTargetBlank": true, "allowText": true } },
        "any_plain":   { "type": "Link", "config": { "label": "Unrestricted — plain",                          "allowTargetBlank": true } },
        "any_text":    { "type": "Link", "config": { "label": "Unrestricted — allowText (= our real CTA)",     "allowTargetBlank": true, "allowText": true } },
        "media_plain": { "type": "Link", "config": { "label": "Media — plain",          "select": "media",    "allowTargetBlank": true } },
        "media_text":  { "type": "Link", "config": { "label": "Media — allowText",      "select": "media",    "allowTargetBlank": true, "allowText": true } }
      }
    }
  ]
}

2. Simulator page — logs the raw payload (app/slice-simulator/page.tsx)

import {
  SliceSimulator,
  SliceSimulatorParams,
  getSlices,
} from "@prismicio/next";
import { SliceZone } from "@prismicio/react";

import { components } from "../../slices";

export default async function SliceSimulatorPage({
  searchParams,
}: SliceSimulatorParams) {
  const { state } = await searchParams;
  const slices = getSlices(state);

  // Log the RAW `slice.primary` before it reaches any component.
  const report = slices.map((slice) => {
    const s = slice as {
      slice_type?: string;
      variation?: string;
      primary?: Record<string, unknown>;
    };
    return {
      slice: `${s.slice_type}/${s.variation ?? "-"}`,
      primary: s.primary ?? {},
    };
  });
  console.info("[slice-simulator] primary:", JSON.stringify(report, null, 2));

  return (
    <SliceSimulator>
      {/* Debug panel: the exact payload the Page Builder sends */}
      <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
        {JSON.stringify(report, null, 2)}
      </pre>
      <SliceZone slices={slices} components={components} />
    </SliceSimulator>
  );
}

3. Slice component — renders each link so you can see which disappear (slices/SliceLinkTest/index.tsx)

import { isFilled, type Content } from "@prismicio/client";
import { PrismicNextLink } from "@prismicio/next";
import type { SliceComponentProps } from "@prismicio/react";

// One Link field per config: select none/web/document/media × allowText off/on.
export default function SliceLinkTest({
  slice,
}: SliceComponentProps<Content.SliceLinkTestSlice>) {
  const p = slice.primary;
  const fields = {
    plain_web: p.plain_web,
    plain_doc: p.plain_doc,
    text_web: p.text_web,
    text_doc: p.text_doc,
    any_plain: p.any_plain,
    any_text: p.any_text,
    media_plain: p.media_plain,
    media_text: p.media_text,
  };

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 8, padding: 32 }}>
      {Object.entries(fields).map(([name, field]) =>
        isFilled.link(field) ? (
          <PrismicNextLink key={name} field={field} />
        ) : null,
      )}
    </div>
  );
}

Thanks so much for the thorough reproduction, @nyvia.dk! :slightly_smiling_face:

You’re right on both points, and I can confirm both issues are happening on our side. They’re coming from the code that builds the payload sent to /slice-simulator, not from @prismicio/next or your implementation. That’s also why the published Content API returns everything correctly and only the live preview is affected.

There are two separate issues:

1. Unrestricted Link fields (link_type: "Any")

When a Link field doesn’t have a type restriction, the preview payload ends up being serialized as just { "link_type": "Any" }. That means the URL and text are dropped, so isFilled.link() correctly considers it empty and the CTA doesn’t render.

As you found, this isn’t related to allowText. Restricting the field to web or media makes it come through correctly, which is the best workaround for the moment.

2. Document links

In preview, page links are currently coming through as { "type": "broken_type", "isBroken": true }, which can cause problems because link_type is missing.

I've made sure both are captured on our end. Thanks again for your feedback.