diff --git a/apps/petrinaut-website/src/examples/embedded-example-page.tsx b/apps/petrinaut-website/src/examples/embedded-example-page.tsx
new file mode 100644
index 00000000000..26937e40a20
--- /dev/null
+++ b/apps/petrinaut-website/src/examples/embedded-example-page.tsx
@@ -0,0 +1,92 @@
+import { lazy, Suspense, type FunctionComponent } from "react";
+
+import { css } from "@hashintel/ds-helpers/css";
+
+import { getReadonlyExampleHandle } from "./readonly-example-handle";
+import { useSharedSearchNavigation } from "./use-shared-search-navigation";
+
+import type { LoadedExample } from "./catalog";
+import type { SharedExampleSearch } from "./example-search";
+
+const LazyPetrinaut = lazy(async () => {
+ const { Petrinaut } = await import("@hashintel/petrinaut/ui");
+ return { default: Petrinaut };
+});
+
+// The page frame and the loading fallback render outside Petrinaut, and the
+// design-system tokens are declared on `.petrinaut-root` (`cssVarRoot` in
+// `scopedThemeConfig`), so token values do not resolve here. These use
+// literals, as the site's other chrome does.
+const pageStyle = css({
+ width: "[100vw]",
+ height: "[100vh]",
+ minWidth: "0",
+ minHeight: "0",
+ overflow: "hidden",
+ backgroundColor: "[#f6f7f8]",
+});
+
+const loadingStyle = css({
+ display: "flex",
+ width: "[100%]",
+ height: "[100%]",
+ alignItems: "center",
+ justifyContent: "center",
+ color: "[#4b5563]",
+ fontSize: "[14px]",
+});
+
+const embedTitleStyle = css({
+ minWidth: "0",
+ overflow: "hidden",
+ color: "neutral.s90",
+ fontSize: "sm",
+ fontWeight: "medium",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+});
+
+export type EmbeddedExamplePageProps = {
+ example: LoadedExample;
+ /** Writes the shared search subset back to the embed URL. */
+ onSearchChange: (
+ search: SharedExampleSearch,
+ history: "push" | "replace",
+ ) => void;
+ search: SharedExampleSearch;
+};
+
+/**
+ * Embed of an example: the full Petrinaut component in its read-only
+ * presentation, navigated through the shared search contract.
+ */
+export const EmbeddedExamplePage: FunctionComponent<
+ EmbeddedExamplePageProps
+> = ({ example, onSearchChange, search }) => {
+ const handle = getReadonlyExampleHandle(example);
+ const navigation = useSharedSearchNavigation(search, onSearchChange, {
+ // The embed lives in an iframe; it must not grow the host page's history.
+ historyPolicy: () => "replace",
+ });
+
+ return (
+
+ Loading Petrinaut…}
+ >
+ {example.catalog.title}
+ ),
+ }}
+ title={example.catalog.title}
+ />
+
+
+ );
+};
diff --git a/apps/petrinaut-website/src/routes/-embed-status-panel.tsx b/apps/petrinaut-website/src/routes/-embed-status-panel.tsx
new file mode 100644
index 00000000000..52330d2fc67
--- /dev/null
+++ b/apps/petrinaut-website/src/routes/-embed-status-panel.tsx
@@ -0,0 +1,49 @@
+import { css } from "@hashintel/ds-helpers/css";
+
+// This panel replaces the embed when there is no Petrinaut to render, so it
+// sits outside `.petrinaut-root`, where the design-system tokens are declared
+// (`cssVarRoot` in `scopedThemeConfig`). Token values would not resolve, so
+// these are literals, matching the site's own not-found page.
+const panelStyle = css({
+ display: "flex",
+ width: "[100%]",
+ height: "[100%]",
+ alignItems: "center",
+ justifyContent: "center",
+ padding: "[24px]",
+ backgroundColor: "[#f6f7f8]",
+ fontFamily: "[Inter, system-ui, sans-serif]",
+ textAlign: "center",
+});
+
+const titleStyle = css({
+ color: "[#1f2933]",
+ fontSize: "[14px]",
+ fontWeight: "[600]",
+});
+
+const bodyStyle = css({
+ marginTop: "[4px]",
+ color: "[#4b5563]",
+ fontSize: "[12px]",
+});
+
+/**
+ * Fallback for the embed route, sized for a frame rather than a page. The
+ * embed renders inside someone else's layout, so a failure stays small and
+ * carries no link: a link here would navigate the frame to this site.
+ */
+export const EmbedStatusPanel = ({
+ body,
+ title,
+}: {
+ body: string;
+ title: string;
+}) => (
+
+);
diff --git a/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx b/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx
new file mode 100644
index 00000000000..e37432ba788
--- /dev/null
+++ b/apps/petrinaut-website/src/routes/embed.examples.$slug.tsx
@@ -0,0 +1,69 @@
+import {
+ createFileRoute,
+ notFound,
+ useLoaderData,
+ useNavigate,
+ useSearch,
+} from "@tanstack/react-router";
+
+import { isExampleSlug, loadExample } from "../examples/catalog";
+import { EmbeddedExamplePage } from "../examples/embedded-example-page";
+import { validateSharedExampleSearch } from "../examples/example-search";
+import { EmbedStatusPanel } from "./-embed-status-panel";
+
+const routePath = "/embed/examples/$slug" as const;
+
+function EmbeddedExampleRoute() {
+ const navigate = useNavigate({ from: routePath });
+ const example = useLoaderData({ from: routePath });
+
+ return (
+ {
+ void navigate({ replace: history === "replace", search });
+ }}
+ search={useSearch({ from: routePath })}
+ />
+ );
+}
+
+export const Route = createFileRoute("/embed/examples/$slug")({
+ beforeLoad: ({ params }) => {
+ if (!isExampleSlug(params.slug)) {
+ throw notFound();
+ }
+ },
+ component: EmbeddedExampleRoute,
+ // The editor is imported lazily, so a chunk that fails to load throws after
+ // mount. Without a boundary here the root unmounts and the frame goes blank
+ // on someone else's page.
+ errorComponent: () => (
+
+ ),
+ loader: ({ params }) => {
+ if (!isExampleSlug(params.slug)) {
+ throw notFound();
+ }
+ return loadExample(params.slug);
+ },
+ // The site-wide not-found page is a full viewport panel with a link that
+ // would navigate the embedder's frame, so the embed answers for itself.
+ notFoundComponent: () => (
+
+ ),
+ // Unknown query params are dropped by `validateSearch` and left in the URL
+ // on purpose. Canonicalizing them with a `redirect({ href })` reloads the
+ // document inside the frame, and TanStack Router only rewrites the URL when
+ // the parsed search changes, so such a redirect re-triggers itself.
+ validateSearch: validateSharedExampleSearch,
+});