Blog article

A single blog article with its author, tags, likes, comments and related articles.

Framework: TanStack Start

This page is written for TanStack Start, the default and currently supported framework. See what depends on the framework.

The customer reads one post: title, date, author, cover image, body, tags, likes, comments and a sidebar of related articles. Customise this route to change the article layout, keep the page's hook slots, or fix the 404 behaviour for unknown articles.

URL

URLExample
/blog/$slug/a-{$id}/blog/launch-day/a-482
/{locale}/blog/$slug/a-{$id}/ar/blog/launch-day/a-482

Params

NameTypeExampleDescription
slugstringlaunch-dayFor readable URLs. The loader never reads it.
idstring482The article id. The a- in front is part of the URL and is removed before the loader runs.
localestring | undefinedarOptional (locale prefix).

There are no search params.

Try it

This is a blog article as the engine renders it, running against the demo store. Switch the language or the width.

/ar/blog/-/a-1673272609Open
Route
/{locale}/blog/$slug/a-{$id}
This address becomes
  • locale = ar
  • path = /blog/-/a-1673272609

Route file

The engine writes this into app/routes/blog_.$slug.a-$id.tsx. The blog_ prefix makes the page a sibling of /blog, not a child of it.

app/routes/blog_.$slug.a-$id.tsx
// @auto-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';

/**
 * BlogSingle route configuration.
 * Loads page data via loader and renders the BlogSingle component.
 */
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} />;
}

Delete the // @auto-generated line to own the file. Keep the path in createFileRoute.

A customised version

This replaces the component with a minimal article layout. It keeps the engine's loader and head, keeps two hook slots, and sets a key so state resets when you move from one article to the next.

app/routes/blog_.$slug.a-$id.tsx
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';
import { HookSlot } from '@salla.sa/twilight-theme-engine/hooks';
import { Breadcrumb, Link } from '@salla.sa/twilight-theme-engine/components/common';

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: ArticlePage,
});

function ArticlePage() {
  const { page, article, related = [] }: BlogSinglePageProps = Route.useLoaderData();
  return (
    <article key={article.id} className="container">
      <Breadcrumb page={page} />
      <HookSlot name="blog:single.start" />
      <h1>{article.name}</h1>
      <p>
        By <Link to={article.author.url}>{article.author.name}</Link>
      </p>
      <div dangerouslySetInnerHTML={{ __html: article.description ?? '' }} />
      {article.tags?.map((tag) => (
        <Link key={tag.id} to={tag.url}>
          #{tag.name}
        </Link>
      ))}
      <HookSlot name="blog:single.end" />
      <ul>
        {related.map((r) => (
          <li key={r.id}>
            <Link to={r.url}>{r.name}</Link>
          </li>
        ))}
      </ul>
    </article>
  );
}

What it loads

BlogSingle.loader returns BlogSinglePageProps:

FieldTypeWhat it is
pagePagetitle and id of the article, breadcrumbs Home, Blog, article
articleArticleDetailid, name, description (the HTML body), image, url, author, created_at, stats, tags, categories, meta
relatedArticleSummary[]Copy of article.related

It makes one request through the blog API module, on the server for the first request and in the browser for later navigations.

Likes and comments are not part of the loader data. They load in the browser and appear when the store's blog.allow_likes_and_comments setting is on. article.description is HTML and the stock page renders it as such.

Hook slots: blog:single.start, blog:single.end, blog:single.related.start, blog:single.related.end.

Head and SEO

BlogSingle.head uses article.meta.title (else article.name), meta.description (else the article text) and meta.canonical (else article.url). It also sets an Open Graph title and image (meta.og_image, else article.image) and hreflang links. See SEO and head.

Edge cases

  • Unknown or deleted article id. The loader throws a not-found error, so the customer sees the not-found page instead of a server error.
  • Wrong $slug. It is not checked and there is no redirect: /blog/anything/a-482 shows article 482. The canonical link points at article.url.
  • Non-numeric id. a-abc is sent to the API as is, so it fails like an unknown id.
  • Slow API. Requests time out after 8 seconds and the error page shows.
  • No related articles. The sidebar block is hidden.
  • Locale. The locale you pass to the loader is not used; the article language follows the request.

See also

On this page