The web half of the UXF Page Builder:
PageRenderer renders a page's sections with the project's section components: a published page from the public
page API (PublicPageData of @uxf/page-builder) as well as a revision in the editor;@uxf/page-builder protocol), the selection overlay,
drag and drop, and inline editing (InlineText, InlineRichText);| Import | What | Where it runs |
|---|---|---|
@uxf/page-builder-web | PageRenderer, SectionElement, InlineText, InlineRichText, useIsInlineEditing, PageBuilderWebProvider, normaliseOrigin, PAGE_BUILDER_WEB_ICONS, types | importable from a Server Component; the components and the hook are client components ("use client") |
@uxf/page-builder-web/pages | createPageBuilderWebPages (Pages Router) and the page components (EditPageRevisionPage, SharedPreviewPage, …) | the page components are client components |
@uxf/page-builder-web/server | fetchPageRender, fetchSharedSectionRender, fetchSharedPreview, fetchSectionExample, fetchPublicPageByUrl, publicPageMetadata, PageRenderNotFoundError, getEditorPageHeaders, getPageBuilderRouteHeaders, SHARED_PREVIEW_HEADERS | server only: Server Components, route handlers, getServerSideProps; no React |
yarn add @uxf/page-builder-web @uxf/page-builder
Peer dependencies: @uxf/core, @uxf/core-react, @uxf/wysiwyg, next, react, react-dom (see
package.json).
import { normaliseOrigin, PageBuilderWebProvider, type PageBuilderWebConfig } from "@uxf/page-builder-web";
const config: PageBuilderWebConfig = {
registry, // generated: `uxf-page-builder-generator` of @uxf/page-builder, from uxf:page-builder:schema-gen
sectionComponents, // { hero: HeroSection, … } by section type
shellOrigin: normaliseOrigin(process.env.NEXT_PUBLIC_EDITOR_SHELL_ORIGIN),
};
Section components receive { content }, the API's public output of their own section type, and may use InlineText
/ InlineRichText for the fields the editor edits in place; outside the editor they render plain text. Type the map
with SectionComponents and the union of the public outputs the API generator writes (uxf:gen, e.g.
PageBuilderSectionContentPublicOutput): a section type without a component, or a component of another type's content,
then fails the type check instead of rendering nothing.
import type { SectionComponents } from "@uxf/page-builder-web";
import type { PageBuilderSectionContentPublicOutput } from "@generated/page-builder-api";
export const sectionComponents: SectionComponents<PageBuilderSectionContentPublicOutput> = {
hero: HeroSection, // (props: { content: PageBuilderHeroSectionPublicResponse }) => ReactNode
faq: FaqSection,
};
The root entry works in the App Router (the default target). PageRenderer reads the config from
PageBuilderWebProvider, so both are client components: a Server Component may render
<PageRenderer layout={page.layout} sections={page.sections} /> with the data it fetched, as long as the provider sits
above it. The config holds the section components (functions), which a Server Component cannot pass as props, so
render the provider from the project's own client module:
// src/page-builder/page-builder-provider.tsx
"use client";
import { PageBuilderWebProvider } from "@uxf/page-builder-web";
import { ReactNode } from "react";
import { pageBuilderWebConfig } from "./page-builder-config";
export function PageBuilderProvider(props: { children: ReactNode }) {
return <PageBuilderWebProvider config={pageBuilderWebConfig}>{props.children}</PageBuilderWebProvider>;
}
wrapSection of PageRenderer is a function too and can only be passed from a client component.
The backoffice opens these paths (WEB_ROUTES in @uxf/page-builder).
createPageBuilderWebPages from @uxf/page-builder-web/pages builds the pages once; the project's route files only
re-export them:
// src/zones/page-builder-zone/pages.ts
import { createPageBuilderWebPages } from "@uxf/page-builder-web/pages";
export const pageBuilderPages = createPageBuilderWebPages({
config,
apiUrl: process.env.NEXT_PUBLIC_FRONTEND_URL ?? "",
});
// src/pages/_edit/page-revision/[id].tsx
export const getServerSideProps = pageBuilderPages.EditPageRevision.getServerSideProps;
export default pageBuilderPages.EditPageRevision.default;
| Path | Page |
|---|---|
/_edit/page-revision/[id] | EditPageRevision: the revision in the visual editor's iframe; ?preview=1 for the preview window |
/_edit/shared-section-revision/[id] | EditSharedSectionRevision: one shared section revision for its preview |
/_preview/[token] | SharedPreview: a preview opened through a shared link, without login |
/_edit/section-example/[type] | SectionExample: a section type with its registry example, for its card in the add section drawer |
apiUrl is the base the SSR fetches call the API through (usually the web's own URL, which proxies /api). The edit
pages forward the visitor's cookie, so the API sees the backoffice login; a revision the visitor may not see answers 404. They also send frame-ancestors with the shell origin, X-Robots-Tag: noindex and Cache-Control: no-store. The
shared preview sends X-Robots-Tag: noindex, Cache-Control: no-store and Referrer-Policy: no-referrer on every
answer, its 404 and error pages included.
Errors of the fetchers name the endpoint, never the URL: a shared preview's token does not end up in logs or Sentry.
There is no factory for the App Router yet (follow-up). A project composes each route from @uxf/page-builder-web/server
and the page components of @uxf/page-builder-web/pages, inside its PageBuilderProvider (see Setup):
// src/app/%5Fedit/page-revision/[id]/page.tsx (`%5F`: a folder starting with `_` is private in the App Router)
import { getDefaultLanguage, readRouteLang } from "@uxf/page-builder";
import { EditPageRevisionPage, getPreviewMode, getPreviewSections } from "@uxf/page-builder-web/pages";
import { fetchPageRender, PageRenderNotFoundError } from "@uxf/page-builder-web/server";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { registry } from "@generated/page-builder/registry"; // generated by uxf-page-builder-generator
export default async function Page(props: PageProps<"/_edit/page-revision/[id]">) {
const { id } = await props.params;
const query = await props.searchParams;
try {
const render = await fetchPageRender({
baseUrl: process.env.NEXT_PUBLIC_FRONTEND_URL ?? "",
cookie: (await cookies()).toString(),
pageRevisionId: Number(id),
lang: readRouteLang(query, getDefaultLanguage(registry)),
});
const preview = getPreviewMode(query);
if (preview !== null && getPreviewSections(render.sections, preview) === null) {
notFound();
}
return <EditPageRevisionPage preview={preview} render={render} />;
} catch (error) {
if (error instanceof PageRenderNotFoundError) {
notFound();
}
throw error;
}
}
A Server Component cannot set response headers, so set them in next.config. getPageBuilderRouteHeaders(shellOrigin)
returns them for every route above, in the shape headers() expects: the edit routes get
getEditorPageHeaders(shellOrigin) (frame-ancestors with the shell origin, X-Robots-Tag: noindex,
Cache-Control: no-store; only the origin of the configured URL is used, so the value cannot add another source or
directive), /_preview/[token] gets SHARED_PREVIEW_HEADERS.
// next.config.ts
import { getPageBuilderRouteHeaders } from "@uxf/page-builder-web/server";
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return getPageBuilderRouteHeaders(process.env.NEXT_PUBLIC_EDITOR_SHELL_ORIGIN ?? null);
},
};
export default nextConfig;
A project that already returns headers of its own spreads them into the same list. In middleware/proxy, set
getEditorPageHeaders(shellOrigin) or SHARED_PREVIEW_HEADERS on the response of the matching path instead.
fetchSharedPreview rejects a value that cannot be a token with PageRenderNotFoundError without calling the API,
and every fetcher gives up after 10 seconds with an error that names the endpoint.
PageRenderer renders a published page's sections in the groups of its layout. fetchPublicPageByUrl asks the public
read API for the page published at a URL (GET /api/app/page-builder/public/page-by-url, uxf/page-builder 3.88+):
its layout, meta data and sections, or PageRenderNotFoundError for a URL without a published page.
publicPageMetadata turns the meta data into App Router metadata. A catch-all route of the web composes them:
// src/app/(web)/[[...slug]]/page.tsx (inside a layout with the project's PageBuilderProvider)
import { PageRenderer } from "@uxf/page-builder-web";
import { fetchPublicPageByUrl, PageRenderNotFoundError, publicPageMetadata } from "@uxf/page-builder-web/server";
import { notFound } from "next/navigation";
async function loadPage(props: PageProps<"/[[...slug]]">) {
const { slug } = await props.params;
try {
return await fetchPublicPageByUrl({
baseUrl: process.env.NEXT_PUBLIC_FRONTEND_URL ?? "",
url: `/${(slug ?? []).join("/")}`,
lang: "cs",
revalidate: 60, // published changes show within a minute
});
} catch (error) {
if (error instanceof PageRenderNotFoundError) {
notFound();
}
throw error;
}
}
export async function generateMetadata(props: PageProps<"/[[...slug]]">) {
return publicPageMetadata(await loadPage(props));
}
export default async function Page(props: PageProps<"/[[...slug]]">) {
const page = await loadPage(props);
return <PageRenderer layout={page.layout} sections={page.sections} />;
}
Next.js deduplicates the two identical fetches of one request. The project's own routes take precedence over the
catch-all; a multilingual web takes the language from its own routing (prefix, domain) instead of "cs". Publishing
does not purge Next's cache: revalidate is how long an outdated page may be served.
| Variable | Where | Value |
|---|---|---|
NEXT_PUBLIC_EDITOR_SHELL_ORIGIN | web | Origin of the backoffice. The edit pages allow it in frame-ancestors and the bridge accepts messages only from it. Empty when backoffice and web share an origin. |
NEXT_PUBLIC_FRONTEND_URL | web | The web's own URL: the base of the SSR fetches (apiUrl / baseUrl), which reach the API through the web's /api proxy. |
NEXT_PUBLIC_WEB_FRONTEND_URL | backoffice (webUrl) | URL of the web the visual editor and the previews open in their iframes. |
Locally the web and the backoffice run on different ports, so they are different origins: set
NEXT_PUBLIC_EDITOR_SHELL_ORIGIN to the backoffice's local origin (e.g. http://localhost:3011) and the backoffice's
webUrl to the local web, otherwise the canvas stays empty (the browser refuses the frame). On a review or production
stage where the backoffice runs on the web's host (e.g. under /admin), leave it empty.
The SSR fetches of the edit pages go to NEXT_PUBLIC_FRONTEND_URL and forward only the visitor's cookie. Behind HTTP
basic auth (UXF review environments) check that this request is not refused; point apiUrl at an address the server
reaches without it if it is.
When a project uses a local checkout of the packages (file: dependencies, never committed), add them to
transpilePackages in next.config (["@uxf/page-builder", "@uxf/page-builder-web"]): the dev server then bundles the
rebuilt files instead of keeping the first ones it required from node_modules.
The editor's texts (overlay, inline editing, section placeholders, the shared preview bar) go through
useUxfTranslation of @uxf/core-react, namespace uxf-page-builder-web. The package ships the dictionaries at
@uxf/page-builder-web/translations/{cs,de,en,sk}.json (the source is @uxf/page-builder-web/translations/translations);
merge them into the translation function you pass to UiContextProvider. Without them the editor shows the keys.
content: "./node_modules/@uxf/page-builder-web/**/*.js".@uxf/wysiwyg: import @uxf/wysiwyg/styles/styles.css and register its translations.PAGE_BUILDER_WEB_ICONS lists the icons the package needs in the project's icons.config.js. It is empty today.
MIT