diff --git a/platform/loader-html-hydrate/src/hydrater.ts b/platform/loader-html-hydrate/src/hydrater.ts
index c79707212..1839e62cf 100644
--- a/platform/loader-html-hydrate/src/hydrater.ts
+++ b/platform/loader-html-hydrate/src/hydrater.ts
@@ -29,6 +29,20 @@ export class PlasmicHtmlHydrater {
element.getAttribute("data-plasmic-prefetched-query-data") || "{}"
);
+ // Read page params data attributes for dynamic route support.
+ // Note: pageRoute is read but not passed to hydrateFromElement because
+ // the loader-react package doesn't support it in the hydration flow.
+ // This means $ctx.params and $ctx.query will work, but $ctx.pageRoute
+ // and $ctx.pagePath won't be available after hydration.
+ const _pageRoute =
+ element.getAttribute("data-plasmic-page-route") || undefined;
+ const pageParams = JSON.parse(
+ element.getAttribute("data-plasmic-page-params") || "{}"
+ );
+ const pageQuery = JSON.parse(
+ element.getAttribute("data-plasmic-page-query") || "{}"
+ );
+
const loader = initPlasmicLoader({
projects: [
{
@@ -52,7 +66,10 @@ export class PlasmicHtmlHydrater {
globalVariants,
componentProps,
prefetchedQueryData,
- }
+ // Pass page params if present (for dynamic route support)
+ ...(Object.keys(pageParams).length > 0 && { pageParams }),
+ ...(Object.keys(pageQuery).length > 0 && { pageQuery }),
+ } as any // Type assertion needed - npm package types don't include pageParams/pageQuery
);
element.setAttribute("data-plasmic-hydrating", "false");
element.setAttribute("data-plasmic-hydrated", "true");
diff --git a/platform/wab/src/wab/server/loader/gen-html-bundle.ts b/platform/wab/src/wab/server/loader/gen-html-bundle.ts
index bd3c013e3..a3eb52af3 100644
--- a/platform/wab/src/wab/server/loader/gen-html-bundle.ts
+++ b/platform/wab/src/wab/server/loader/gen-html-bundle.ts
@@ -11,6 +11,7 @@ import {
} from "@plasmicapp/loader-react";
import React from "react";
import ReactDOMServer from "react-dom/server";
+import { PageParams } from "./page-params";
export async function genLoaderHtmlBundle(opts: {
projectId: string;
@@ -22,6 +23,9 @@ export async function genLoaderHtmlBundle(opts: {
prepass?: boolean;
componentProps?: any;
globalVariants?: GlobalVariantSpec[];
+ pageRoute?: string;
+ pageParams?: PageParams;
+ pageQuery?: PageParams;
}) {
const {
projectId,
@@ -33,6 +37,9 @@ export async function genLoaderHtmlBundle(opts: {
componentProps,
globalVariants,
prepass,
+ pageRoute,
+ pageParams,
+ pageQuery,
} = opts;
// Set the data host for data source operations during SSR prepass.
@@ -65,7 +72,9 @@ export async function genLoaderHtmlBundle(opts: {
prefetchedData: data,
componentProps,
globalVariants,
- }
+ pageParams,
+ pageQuery,
+ } as any // Type assertion needed - npm package types don't include pageParams/pageQuery
)
: undefined;
@@ -77,7 +86,9 @@ export async function genLoaderHtmlBundle(opts: {
componentProps,
globalVariants,
prefetchedQueryData,
- }
+ pageParams,
+ pageQuery,
+ } as any // Type assertion needed - npm package types don't include pageParams/pageQuery
);
const outerElement = React.createElement(
@@ -97,6 +108,13 @@ export async function genLoaderHtmlBundle(opts: {
hydrate && embedHydrate && prepass
? JSON.stringify(prefetchedQueryData || {})
: "",
+ "data-plasmic-page-route": hydrate ? pageRoute || "" : "",
+ "data-plasmic-page-params": hydrate
+ ? JSON.stringify(pageParams || {})
+ : "",
+ "data-plasmic-page-query": hydrate
+ ? JSON.stringify(pageQuery || {})
+ : "",
dangerouslySetInnerHTML: { __html: innerHtml },
}),
hydrate &&
diff --git a/platform/wab/src/wab/server/loader/page-params.spec.ts b/platform/wab/src/wab/server/loader/page-params.spec.ts
new file mode 100644
index 000000000..de8e2072b
--- /dev/null
+++ b/platform/wab/src/wab/server/loader/page-params.spec.ts
@@ -0,0 +1,196 @@
+import { BadRequestError } from "@/wab/shared/ApiErrors/errors";
+import {
+ getInvalidParamKeys,
+ isValidParamsObject,
+ isValidParamValue,
+ isValidRoutePattern,
+ parsePageParams,
+ parsePageQuery,
+ parsePageRoute,
+} from "./page-params";
+
+describe("page-params", () => {
+ describe("isValidRoutePattern", () => {
+ it("returns true for valid route patterns", () => {
+ expect(isValidRoutePattern("/")).toBe(true);
+ expect(isValidRoutePattern("/products")).toBe(true);
+ expect(isValidRoutePattern("/products/[slug]")).toBe(true);
+ expect(isValidRoutePattern("/products/[...catchall]")).toBe(true);
+ expect(isValidRoutePattern("/a/b/c/[id]")).toBe(true);
+ });
+
+ it("returns false for invalid route patterns", () => {
+ expect(isValidRoutePattern("")).toBe(false);
+ expect(isValidRoutePattern("products")).toBe(false);
+ expect(isValidRoutePattern("products/[slug]")).toBe(false);
+ });
+ });
+
+ describe("isValidParamValue", () => {
+ it("returns true for valid param values", () => {
+ expect(isValidParamValue("hello")).toBe(true);
+ expect(isValidParamValue("")).toBe(true);
+ expect(isValidParamValue([])).toBe(true);
+ expect(isValidParamValue(["a", "b", "c"])).toBe(true);
+ });
+
+ it("returns false for invalid param values", () => {
+ expect(isValidParamValue(123)).toBe(false);
+ expect(isValidParamValue(null)).toBe(false);
+ expect(isValidParamValue(undefined)).toBe(false);
+ expect(isValidParamValue({})).toBe(false);
+ expect(isValidParamValue([1, 2, 3])).toBe(false);
+ expect(isValidParamValue(["a", 1])).toBe(false);
+ });
+ });
+
+ describe("isValidParamsObject", () => {
+ it("returns true for valid params objects", () => {
+ expect(isValidParamsObject({})).toBe(true);
+ expect(isValidParamsObject({ slug: "hello" })).toBe(true);
+ expect(isValidParamsObject({ slug: "hello", id: "123" })).toBe(true);
+ expect(isValidParamsObject({ catchall: ["a", "b"] })).toBe(true);
+ expect(isValidParamsObject({ slug: "hello", parts: ["a", "b"] })).toBe(
+ true
+ );
+ });
+
+ it("returns false for invalid params objects", () => {
+ expect(isValidParamsObject(null)).toBe(false);
+ expect(isValidParamsObject(undefined)).toBe(false);
+ expect(isValidParamsObject("string")).toBe(false);
+ expect(isValidParamsObject([])).toBe(false);
+ expect(isValidParamsObject([{ slug: "hello" }])).toBe(false);
+ expect(isValidParamsObject({ slug: 123 })).toBe(false);
+ expect(isValidParamsObject({ slug: null })).toBe(false);
+ });
+ });
+
+ describe("getInvalidParamKeys", () => {
+ it("returns empty array for valid params", () => {
+ expect(getInvalidParamKeys({})).toEqual([]);
+ expect(getInvalidParamKeys({ slug: "hello" })).toEqual([]);
+ expect(getInvalidParamKeys({ slug: ["a", "b"] })).toEqual([]);
+ });
+
+ it("returns keys with invalid values", () => {
+ expect(getInvalidParamKeys({ slug: 123 })).toEqual(["slug"]);
+ expect(getInvalidParamKeys({ a: "valid", b: 123, c: null })).toEqual([
+ "b",
+ "c",
+ ]);
+ });
+ });
+
+ describe("parsePageRoute", () => {
+ it("returns undefined for empty/null/undefined input", () => {
+ expect(parsePageRoute()).toBeUndefined();
+ expect(parsePageRoute(undefined)).toBeUndefined();
+ expect(parsePageRoute(null)).toBeUndefined();
+ expect(parsePageRoute("")).toBeUndefined();
+ });
+
+ it("parses valid route patterns", () => {
+ expect(parsePageRoute("/")).toBe("/");
+ expect(parsePageRoute("/products")).toBe("/products");
+ expect(parsePageRoute("/products/[slug]")).toBe("/products/[slug]");
+ expect(parsePageRoute("/[...catchall]")).toBe("/[...catchall]");
+ });
+
+ it("throws BadRequestError for non-string input", () => {
+ expect(() => parsePageRoute(123)).toThrow(BadRequestError);
+ expect(() => parsePageRoute({})).toThrow(BadRequestError);
+ expect(() => parsePageRoute([])).toThrow(BadRequestError);
+ });
+
+ it("throws BadRequestError for routes not starting with /", () => {
+ expect(() => parsePageRoute("products")).toThrow(BadRequestError);
+ expect(() => parsePageRoute("products/[slug]")).toThrow(BadRequestError);
+ });
+ });
+
+ describe("parsePageParams", () => {
+ it("returns undefined for empty/null/undefined input", () => {
+ expect(parsePageParams()).toBeUndefined();
+ expect(parsePageParams(undefined)).toBeUndefined();
+ expect(parsePageParams(null)).toBeUndefined();
+ expect(parsePageParams("")).toBeUndefined();
+ });
+
+ it("parses valid JSON params", () => {
+ expect(parsePageParams("{}")).toEqual({});
+ expect(parsePageParams('{"slug":"hello"}')).toEqual({ slug: "hello" });
+ expect(parsePageParams('{"slug":"hello","id":"123"}')).toEqual({
+ slug: "hello",
+ id: "123",
+ });
+ expect(parsePageParams('{"catchall":["a","b","c"]}')).toEqual({
+ catchall: ["a", "b", "c"],
+ });
+ });
+
+ it("throws BadRequestError for non-string input", () => {
+ expect(() => parsePageParams(123)).toThrow(BadRequestError);
+ expect(() => parsePageParams({})).toThrow(BadRequestError);
+ });
+
+ it("throws BadRequestError for invalid JSON", () => {
+ expect(() => parsePageParams("{invalid}")).toThrow(BadRequestError);
+ expect(() => parsePageParams("not json")).toThrow(BadRequestError);
+ });
+
+ it("throws BadRequestError for non-object JSON", () => {
+ expect(() => parsePageParams("[]")).toThrow(BadRequestError);
+ expect(() => parsePageParams('"string"')).toThrow(BadRequestError);
+ expect(() => parsePageParams("123")).toThrow(BadRequestError);
+ expect(() => parsePageParams("null")).toThrow(BadRequestError);
+ });
+
+ it("throws BadRequestError for invalid param values", () => {
+ expect(() => parsePageParams('{"slug":123}')).toThrow(BadRequestError);
+ expect(() => parsePageParams('{"slug":null}')).toThrow(BadRequestError);
+ expect(() => parsePageParams('{"slug":[1,2,3]}')).toThrow(BadRequestError);
+ });
+
+ it("includes invalid key names in error message", () => {
+ expect(() => parsePageParams('{"validKey":"ok","badKey":123}')).toThrow(
+ /badKey/
+ );
+ });
+ });
+
+ describe("parsePageQuery", () => {
+ it("returns undefined for empty/null/undefined input", () => {
+ expect(parsePageQuery()).toBeUndefined();
+ expect(parsePageQuery(undefined)).toBeUndefined();
+ expect(parsePageQuery(null)).toBeUndefined();
+ expect(parsePageQuery("")).toBeUndefined();
+ });
+
+ it("parses valid JSON query params", () => {
+ expect(parsePageQuery("{}")).toEqual({});
+ expect(parsePageQuery('{"q":"search"}')).toEqual({ q: "search" });
+ expect(parsePageQuery('{"tags":["a","b"]}')).toEqual({
+ tags: ["a", "b"],
+ });
+ });
+
+ it("throws BadRequestError for non-string input", () => {
+ expect(() => parsePageQuery(123)).toThrow(BadRequestError);
+ expect(() => parsePageQuery({})).toThrow(BadRequestError);
+ });
+
+ it("throws BadRequestError for invalid JSON", () => {
+ expect(() => parsePageQuery("{invalid}")).toThrow(BadRequestError);
+ });
+
+ it("throws BadRequestError for non-object JSON", () => {
+ expect(() => parsePageQuery("[]")).toThrow(BadRequestError);
+ expect(() => parsePageQuery("null")).toThrow(BadRequestError);
+ });
+
+ it("throws BadRequestError for invalid query values", () => {
+ expect(() => parsePageQuery('{"page":123}')).toThrow(BadRequestError);
+ });
+ });
+});
diff --git a/platform/wab/src/wab/server/loader/page-params.ts b/platform/wab/src/wab/server/loader/page-params.ts
new file mode 100644
index 000000000..6d3c0ca69
--- /dev/null
+++ b/platform/wab/src/wab/server/loader/page-params.ts
@@ -0,0 +1,115 @@
+/**
+ * Page params parsing module for HTML loader.
+ *
+ * Provides parsing and validation for dynamic route parameters
+ * (pageRoute, pageParams, pageQuery) used in server-side rendering.
+ */
+
+import { BadRequestError } from "@/wab/shared/ApiErrors/errors";
+
+// --- Types ---
+
+export type PageParamsValue = string | string[];
+export type PageParams = Record;
+
+// --- Pure validation functions ---
+
+export const isValidRoutePattern = (route: string): boolean => {
+ return typeof route === "string" && route.startsWith("/");
+};
+
+export const isValidParamValue = (value: unknown): value is PageParamsValue => {
+ if (typeof value === "string") {
+ return true;
+ }
+ if (Array.isArray(value)) {
+ return value.every((v) => typeof v === "string");
+ }
+ return false;
+};
+
+export const isValidParamsObject = (obj: unknown): obj is PageParams => {
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
+ return false;
+ }
+ return Object.values(obj).every(isValidParamValue);
+};
+
+export const getInvalidParamKeys = (obj: Record): string[] => {
+ return Object.entries(obj)
+ .filter(([_, value]) => !isValidParamValue(value))
+ .map(([key]) => key);
+};
+
+// --- Parsing helpers ---
+
+const isEmptyInput = (value: unknown): boolean => {
+ return value === undefined || value === null || value === "";
+};
+
+const parseJsonObject = (
+ raw: unknown,
+ fieldName: string
+): Record => {
+ if (typeof raw !== "string") {
+ throw new BadRequestError(`${fieldName} must be a JSON string`);
+ }
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ throw new BadRequestError(`${fieldName} must be valid JSON`);
+ }
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new BadRequestError(`${fieldName} must be a JSON object`);
+ }
+ return parsed as Record;
+};
+
+const validateParamValues = (
+ obj: Record,
+ fieldName: string
+): PageParams => {
+ const invalidKeys = getInvalidParamKeys(obj);
+ if (invalidKeys.length > 0) {
+ throw new BadRequestError(
+ `${fieldName} contains invalid values for keys: ${invalidKeys.join(", ")}`
+ );
+ }
+ return obj as PageParams;
+};
+
+// --- Public parse functions ---
+
+export const parsePageRoute = (rawPageRoute?: unknown): string | undefined => {
+ if (isEmptyInput(rawPageRoute)) {
+ return undefined;
+ }
+ if (typeof rawPageRoute !== "string") {
+ throw new BadRequestError("pageRoute must be a string");
+ }
+ if (!isValidRoutePattern(rawPageRoute)) {
+ throw new BadRequestError("pageRoute must start with /");
+ }
+ return rawPageRoute;
+};
+
+export const parsePageParams = (
+ rawPageParams?: unknown
+): PageParams | undefined => {
+ if (isEmptyInput(rawPageParams)) {
+ return undefined;
+ }
+ const parsed = parseJsonObject(rawPageParams, "pageParams");
+ return validateParamValues(parsed, "pageParams");
+};
+
+export const parsePageQuery = (
+ rawPageQuery?: unknown
+): PageParams | undefined => {
+ if (isEmptyInput(rawPageQuery)) {
+ return undefined;
+ }
+ const parsed = parseJsonObject(rawPageQuery, "pageQuery");
+ return validateParamValues(parsed, "pageQuery");
+};
diff --git a/platform/wab/src/wab/server/routes/loader.ts b/platform/wab/src/wab/server/routes/loader.ts
index f7852dab4..186e3f9a5 100644
--- a/platform/wab/src/wab/server/routes/loader.ts
+++ b/platform/wab/src/wab/server/routes/loader.ts
@@ -12,6 +12,11 @@ import {
CodeModule,
LoaderBundleOutput,
} from "@/wab/server/loader/module-bundler";
+import {
+ parsePageParams,
+ parsePageQuery,
+ parsePageRoute,
+} from "@/wab/server/loader/page-params";
import {
parseComponentProps,
parseGlobalVariants,
@@ -672,6 +677,9 @@ export async function buildVersionedLoaderHtml(req: Request, res: Response) {
projectToken: props.token,
componentProps: parseComponentProps(req.query.componentProps),
globalVariants: parseGlobalVariants(req.query.globalVariants),
+ pageRoute: parsePageRoute(req.query.pageRoute),
+ pageParams: parsePageParams(req.query.pageParams),
+ pageQuery: parsePageQuery(req.query.pageQuery),
});
await props.mgr.upsertLoaderPublishmentEntities({
@@ -702,6 +710,9 @@ export async function buildLatestLoaderHtml(req: Request, res: Response) {
projectToken: props.token,
componentProps: parseComponentProps(req.query.componentProps),
globalVariants: parseGlobalVariants(req.query.globalVariants),
+ pageRoute: parsePageRoute(req.query.pageRoute),
+ pageParams: parsePageParams(req.query.pageParams),
+ pageQuery: parsePageQuery(req.query.pageQuery),
});
res.json(result);
});