Skip to content
Twilight React Playground
ثيم رائدaren

PageSingle

route-moduleBeginnerserverbrowserlive demo

A merchant's content page, such as About us or Terms: loads it by id and shows its HTML and comments.

import { PageSingle, PageSingleLazy, pageSingleLoader, PageSingleProps, StaticPage } from '@salla.sa/twilight-theme-engine/routes/page';

In plain words

Merchants write simple pages in the Salla dashboard: About us, Shipping policy, Terms. Their addresses end in page-<id>. PageSingle.loader fetches one by id, and the component shows its title and the HTML the merchant wrote.

Naming trap: PageSingle is the route module. The component on its own is exported as PageSingleLazy.

The store's footer links to these pages: follow one from the home page on Built-in pages.

Signature

const PageSingle: {
  readonly id: 'page-single';
  readonly loader: (
    ctx: { params: { id: string }; locale?: string },
    extend?: (data: PageSingleProps, ctx: { params: { id: string } }) => Record<string, unknown> | Promise<Record<string, unknown>>
  ) => Promise<PageSingleProps>;
  readonly head: (ctx: TwilightContext, data: PageSingleProps) => HeadDescriptor;
  readonly Component: React.LazyExoticComponent<(props: PageSingleProps) => JSX.Element>;
};

const PageSingleLazy: typeof PageSingle.Component;
function pageSingleLoader({ params }: { params: { id: string } }): Promise<PageSingleProps>;

interface PageSingleProps { page: StaticPage }

interface StaticPage extends Page {
  id?: number;
  content?: string;   // the merchant's HTML
  url?: string;
  metadata?: { title?: string; description?: string };
  created_at?: { published_time?: string; modified_time?: string };
}

Try it live

The three parts of one route module, PageSingle, fed the same data: what the loader returns, the head tags built from it, and the page drawn from it.Try this: add a title suffix and watch only the head tags change; then note that the description keeps the &nbsp; the page itself turns into a space.
Storefront canvas · ar · RTL
Runs in the browser…
Controls
The HTML a merchant writes in the dashboard.
Leave empty to use head as it is.
What a theme writes
import { createFileRoute } from '@tanstack/react-router';
import { PageSingle } from '@salla.sa/twilight-theme-engine/routes/page';
import type { PageSingleProps } from '@salla.sa/twilight-theme-engine/routes/page';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

// app/routes/$slug.page-$id.tsx, as the plugin generates it (without its first line)
export const Route = createFileRoute('/{-$locale}/$slug/page-{$id}')({
  // 1. loader: fetch the page, return the Component's props
  loader: ({ params }): Promise<PageSingleProps> =>
    PageSingle.loader({ params: { id: params.id }, locale: params.locale }),
  // 2. head: title, description, canonical and Open Graph tags from the same data
  head: withHead(PageSingle),
  // 3. component: draw the page from the loader data
  component: PageSingleComponent,
});

function PageSingleComponent() {
  const data: PageSingleProps = Route.useLoaderData();
  return <PageSingle.Component {...data} />;
}

Example

app/routes/$slug.page-$id.tsx (generated)
import { createFileRoute } from '@tanstack/react-router';
import { PageSingle } from '@salla.sa/twilight-theme-engine/routes/page';
import type { PageSingleProps } from '@salla.sa/twilight-theme-engine/routes/page';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/$slug/page-{$id}')({
  loader: ({ params }): Promise<PageSingleProps> =>
    PageSingle.loader({ params: { id: params.id }, locale: params.locale }),
  head: withHead(PageSingle),
  component: PageSingleComponent,
});

function PageSingleComponent() {
  const data: PageSingleProps = Route.useLoaderData();
  return <PageSingle.Component {...data} />;
}

How it behaves

  • URL: /{-$locale}/$slug/page-{$id}; only the id is read. The generated route sets no pendingComponent, so client navigations show the router's default PageSkeleton.

  • pageSingleLoader calls page.findOrThrow(id): any failure shows the 404 page. It maps the API's name to page.title and copies content, url, metadata and created_at.

  • head: the title, a description made from the content with HTML tags removed, canonical from page.url, Open Graph type article with the published and modified times, and hreflang alternates.

  • The component replaces &nbsp; with spaces and renders content as HTML. When page.id is set it adds Salla's comments web component for the page.

  • Lazy: outside a route, wrap PageSingleLazy in <Suspense>.

Gotchas

  • head puts the whole content into the description: there is no length cap (Product's is 160 characters) and &nbsp; entities stay in it. Trim it with withHead(PageSingle, (result) => ({ ...result, description: result.description?.slice(0, 160) })).

  • metadata.title and metadata.description, the SEO fields a merchant can fill for the page, are loaded but not used by head.

  • The content is rendered with dangerouslySetInnerHTML and the engine does not sanitise it: it is trusted as the merchant's own HTML. Scripts inside it do not run.

Related

Source and docs