Blog tag

The articles carrying one blog tag, with a sidebar of categories and load-more on scroll.

Framework: TanStack Start

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

The customer sees the articles that carry one tag, under the tag's name, and more load as they scroll. Tag links appear on articles and article cards. Customise this route to change the list layout or the title. This is the blog tag; products have their own tag page.

URL

URLExample
/blog/$slug/tag-{$id}/blog/launch/tag-7
/{locale}/blog/$slug/tag-{$id}/ar/blog/launch/tag-7

Params

NameTypeExampleDescription
slugstringlaunchFor readable URLs. The loader never reads it.
idstring7The tag id. The tag- is part of the URL.
localestring | undefinedarOptional (locale prefix).

Product tags live at /$slug/tag-{$id}, without the /blog prefix.

Search params

None. Paging is not in the URL.

Route file

The engine writes this into app/routes/blog_.$slug.tag-$id.tsx.

app/routes/blog_.$slug.tag-$id.tsx
// @auto-generated
import { createFileRoute } from '@tanstack/react-router';
import { BlogTagRoute } from '@salla.sa/twilight-theme-engine/routes/blog';
import type { BlogTagLoaderData } 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';

/**
 * BlogTagRoute route configuration.
 * Loads page data via loader and renders the BlogTagRoute component.
 */
export const Route = createFileRoute('/{-$locale}/blog/$slug/tag-{$id}')({
  loader: ({ params }): Promise<BlogTagLoaderData> =>
    BlogTagRoute.loader({ params: { slug: params.slug, id: params.id }, locale: params.locale }),
  head: withHead(BlogTagRoute),
  pendingComponent: () => <BlogSkeleton />,
  component: BlogTagRouteComponent,
});

function BlogTagRouteComponent() {
  const data: BlogTagLoaderData = Route.useLoaderData();
  return <BlogTagRoute.Component {...data} />;
}

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

A customised version

This keeps the engine's loader, head and skeleton and draws a plain list of the first batch.

app/routes/blog_.$slug.tag-$id.tsx
import { createFileRoute } from '@tanstack/react-router';
import { BlogTagRoute } from '@salla.sa/twilight-theme-engine/routes/blog';
import type { BlogTagLoaderData } 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, NoContent } from '@salla.sa/twilight-theme-engine/components/common';

export const Route = createFileRoute('/{-$locale}/blog/$slug/tag-{$id}')({
  loader: ({ params }): Promise<BlogTagLoaderData> =>
    BlogTagRoute.loader({ params: { slug: params.slug, id: params.id }, locale: params.locale }),
  head: withHead(BlogTagRoute),
  pendingComponent: () => <BlogSkeleton />,
  component: TagPage,
});

function TagPage() {
  const { page, blogTag, articles }: BlogTagLoaderData = Route.useLoaderData();
  return (
    <div className="container">
      <Breadcrumb page={page} />
      <HookSlot name="blog:tag.start" />
      <h1>#{blogTag.name}</h1>
      {articles.length === 0 ? (
        <NoContent icon="sicon-inbox" message="No articles with this tag" />
      ) : (
        <ul>
          {articles.map((a) => (
            <li key={a.id}>
              <Link to={a.url}>{a.name}</Link>
            </li>
          ))}
        </ul>
      )}
      <HookSlot name="blog:tag.end" />
    </div>
  );
}

What it loads

BlogTagRoute.loader returns BlogTagLoaderData:

FieldTypeWhat it is
pagePagetitle and id of the tag; breadcrumbs Home, Blog, tag
blogTagBlogTagid (a number), name, url
categoriesBlogCategory[]All categories, for the sidebar
articlesArticleSummary[]The first batch of articles
cursorPaginationnext, previous, current

It makes two requests at once: this tag's articles and the blog overview (for the sidebar), through the blog API module. The first request is rendered on the server. Further batches are fetched only in the browser.

Hook slots: blog:tag.start, blog:tag.end.

Head and SEO

BlogTagRoute.head uses blogTag.name as the title, the English text Articles tagged with {name} as the description, blogTag.url as the canonical link, and hreflang links. The description is not translated; override it with withHead(BlogTagRoute, (result) => ({ ...result, description: '...' })). See SEO and head.

Edge cases

  • More articles than one batch. When cursor.next is set, the stock page loads the next batches as the customer scrolls, in the browser only. A custom page that shows only articles shows only the first batch.
  • Empty tag. The page shows the "no articles" state and no heading.
  • Unknown tag id. The loader throws a not-found error, so the customer sees the not-found page instead of a server error.
  • Wrong $slug. Ignored, no redirect.
  • Not the product tag. /blog/x/tag-7 is a blog tag; /x/tag-7 and /tags/7 are product tags.
  • Locale. The locale you pass to the loader is not used; the language follows the request.

See also

On this page