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

BlogSingle

route-moduleBeginnerserverbrowserlive demo

One blog article with its related articles, likes and comments, using the article's own SEO title, description and image when set.

import { BlogSingle, BlogSinglePage, blogSingleLoader, BlogSinglePageProps } from '@salla.sa/twilight-theme-engine/routes/blog';

In plain words

An article address looks like /blog/<slug>/a-<id>. BlogSingle.loader fetches that article, and the page shows it with a like button, comments and related articles.

When the merchant filled in the article's SEO fields, the page title, description and share image come from those fields instead of the article's name and text. Open an article from the blog on Built-in pages.

Signature

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

const BlogSinglePage: typeof BlogSingle.Component;
function blogSingleLoader({ params }: { params: { slug: string; id: string } }): Promise<BlogSinglePageProps>;

interface BlogSinglePageProps {
  page: Page;
  article: ArticleDetail;
  related?: ArticleSummary[];   // article.related
}

Try it live

A real article from this store's blog, drawn by BlogSingle.Component, and the head descriptor BlogSingle.head builds for it.Try this: switch the view to head and compare title with article.meta.title: an SEO field left blank arrives as an empty string, and ?? keeps it.
Real requests to the demo store
Storefront canvas · en · LTR
Runs in the browser…
Controls
What a theme writes
import { createFileRoute } from '@tanstack/react-router';
import { BlogSingle } from '@salla.sa/twilight-theme-engine/routes/blog';
import type { BlogSinglePageProps } from '@salla.sa/twilight-theme-engine/routes/blog';
import { BlogSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/blog/$slug/a-{$id}')({
  loader: ({ params }): Promise<BlogSinglePageProps> =>
    BlogSingle.loader({ params: { slug: params.slug, id: params.id }, locale: params.locale }),
  // An empty SEO field would leave the page without its own title: fall back with ||.
  head: withHead(BlogSingle, (result, _ctx, data) => ({
    ...result,
    title: data.article.meta?.title || data.article.name,
    description: data.article.meta?.description || data.article.description?.replace(/<[^>]+>/g, '').slice(0, 160),
    canonical: data.article.meta?.canonical || data.article.url,
  })),
  pendingComponent: () => <BlogSkeleton />,
  component: BlogSingleComponent,
});

function BlogSingleComponent() {
  const data: BlogSinglePageProps = Route.useLoaderData();
  return <BlogSingle.Component {...data} />;
}

Example

app/routes/blog_.$slug.a-$id.tsx (generated)
import { createFileRoute } from '@tanstack/react-router';
import { BlogSingle } from '@salla.sa/twilight-theme-engine/routes/blog';
import type { BlogSinglePageProps } from '@salla.sa/twilight-theme-engine/routes/blog';
import { BlogSkeleton } from '@salla.sa/twilight-theme-engine/skeleton';
import { withHead } from '@salla.sa/twilight-theme-engine/tanstack';

export const Route = createFileRoute('/{-$locale}/blog/$slug/a-{$id}')({
  loader: ({ params }): Promise<BlogSinglePageProps> =>
    BlogSingle.loader({ params: { slug: params.slug, id: params.id }, locale: params.locale }),
  head: withHead(BlogSingle),
  pendingComponent: () => <BlogSkeleton />,
  component: BlogSingleComponent,
});

function BlogSingleComponent() {
  const data: BlogSinglePageProps = Route.useLoaderData();
  return <BlogSingle.Component {...data} />;
}

How it behaves

  • URL: /{-$locale}/blog/$slug/a-{$id}. The slug is passed on but only the id is used.

  • head: title article.meta.title ?? article.name, description article.meta.description ?? article.description, canonical article.meta.canonical ?? article.url, Open Graph title and image (meta.og_image ?? image), hreflang alternates. No Twitter tags.

  • BlogSinglePage shows the like button and Salla's comments web component only when the store enables settings.blog.allow_likes_and_comments. It uses useBlogLike for the button and useComments to refresh the comments. Slots: blog:single.start, blog:single.end, blog:single.related.start and blog:single.related.end.

  • In the article detail, description is the article's whole HTML body. It is rendered with dangerouslySetInnerHTML (&nbsp; replaced by spaces), and it is also what head falls back to for the meta description.

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

Gotchas

  • A missing article does not show the 404 page. blogSingleLoader calls blog.articles.find without orThrow, so the API's HTTP error reaches the router's default error component, which shows a 400 page (or a 500 page when the API answers with no article).

  • SEO fields the merchant left blank arrive as empty strings (meta: { title: '', description: '', canonical: '', og_image: '' } on the demo store), and head falls back with ??, which keeps an empty string. The head adapter then drops every empty value, so such an article gets no title, description, canonical or share image of its own and keeps the store's title. Fall back with || in withHead's extend, as the demo's code does.

Related

Source and docs