← Files WixARCHIVED FILE

skills/wix-headless-templates/blog/project/src/pages/blog/[...slug].astro

5.66 KB · Oct 8, 2026 · 12:02 UTC

↓ Download file

See the change to this file →

---
// Post page.
//
// This is a Wix ITEM PAGE: the wixMetadata export + <SEO.Tags> below let the site owner edit
// this page's title/description/OG in the dashboard and register the route in the sitemap.
// Keep all three SEO pieces, the [...slug] rest param (a slug may contain "/"), and this
// frontmatter; restyle the template below freely. The body island is client:only — the ricos
// viewer breaks under SSR. The like row and the comments are client:load islands: both read as
// the VIEWER (personal like state, the member's own comments), which SSR cannot do.
import SiteLayout from "../../layouts/SiteLayout.astro";
import RichContent from "../../components/blog/RichContent";
import PostLikeButton from "../../components/blog/PostLikeButton";
import CommentsView from "../../components/blog/CommentsView";
import { PostCard } from "../../components/blog/BlogFeedView";
import { categoryPath, fetchPostBySlug, fetchRelatedPosts, postMetaLine, tagPath } from "../../wix/blog/posts";
import { fetchBlogCategories, fetchTagsByIds, inIdOrder } from "../../wix/blog/taxonomy";
import type { BlogCategory, BlogTag, PostDetail, PostSummary } from "../../wix/blog/types";
import { WIX_APPS } from "@wix/essentials";
import { SEO } from "@wix/seo/components";
import { loadSEOTagsServiceConfig } from "@wix/seo/services";
import { seoTags } from "@wix/seo";

export const wixMetadata = {
  appDefId: WIX_APPS.blogs.id,
  pageIdentifier: WIX_APPS.blogs.postPageMetadata.pageIdentifier,
  identifiers: {
    slug: WIX_APPS.blogs.postPageMetadata.identifiers.slug,
  },
};

// The rest param arrives decoded and re-joined ("a/b" for /blog/a/b) — exactly the stored slug.
const slug = Astro.params.slug;
if (!slug) {
  return new Response(null, { status: 404 });
}

// Behind Wix's proxy the request URL is internal — the real public page URL arrives on
// x-wix-forwarded-url; the SEO service needs the public one.
const forwardedUrl = Astro.request.headers.get("x-wix-forwarded-url");
const pageUrl =
  forwardedUrl && URL.canParse(forwardedUrl) && /^https?:$/.test(new URL(forwardedUrl).protocol)
    ? forwardedUrl
    : Astro.url.href;

let post: PostDetail | null = null;
let allCategories: BlogCategory[] = [];
let postTags: BlogTag[] = [];
let related: PostSummary[] = [];
let seoTagsServiceConfig = null;
try {
  [post, allCategories, seoTagsServiceConfig] = await Promise.all([
    fetchPostBySlug(slug),
    fetchBlogCategories(),
    loadSEOTagsServiceConfig({
      pageUrl,
      itemType: seoTags.ItemType.BLOG_POST,
      itemData: { slug },
    }).catch(() => null),
  ]);
  if (post) {
    // Tags are read BY ID (a blog's tags run to hundreds); the related strip needs the post's ids.
    [postTags, related] = await Promise.all([fetchTagsByIds(post.tagIds), fetchRelatedPosts(post)]);
  }
} catch {
  // Guarded: an unhandled SSR throw truncates the response mid-stream.
}

if (!post) {
  return new Response(null, { status: 404 });
}

// Chips in the POST's order (its categoryIds), ids that resolved to nothing dropped.
const postCategories = inIdOrder(post.categoryIds, allCategories);
// One source of head tags: the SEO service when it resolved (it carries the owner's overrides);
// otherwise the DTO's own seoTitle/seoDescription (override → title; override → excerpt → body).
const head = seoTagsServiceConfig ? {} : { title: post.seoTitle, description: post.seoDescription };
---

<SiteLayout {...head}>
  <SEO.Tags seoTagsServiceConfig={seoTagsServiceConfig} slot="seo-tags" />

  <article class="mx-auto max-w-3xl">
    {
      postCategories.length > 0 && (
        <p class="eyebrow">
          {postCategories.map((c, i) => (
            <>
              {i > 0 && " · "}
              <a href={categoryPath(c.slug)} class="no-underline">
                {c.label}
              </a>
            </>
          ))}
        </p>
      )
    }
    <h1 class="mt-2 text-3xl font-semibold tracking-tight">{post.title}</h1>
    <p class="mt-3 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
      {post.authorAvatarUrl && <img src={post.authorAvatarUrl} alt="" width="24" height="24" class="h-6 w-6 rounded-full object-cover" />}
      {post.authorName && <span>{post.authorName}</span>}
      {post.authorName && postMetaLine(post) && <span aria-hidden="true">·</span>}
      <time datetime={post.dateISO}>{postMetaLine(post)}</time>
    </p>
    {
      post.coverUrl && (
        <div class="mt-6 aspect-[16/9] max-h-[45vh] overflow-hidden rounded-xl bg-secondary">
          <img src={post.coverUrl} alt={post.coverAlt} class="h-full w-full object-cover" />
        </div>
      )
    }
    <div class="mt-8">
      <RichContent client:only="react" content={post.richContent} fallbackParagraphs={post.paragraphs} />
    </div>
    <div class="mt-8 border-t border-border pt-4">
      <PostLikeButton client:load post={post} />
    </div>
    {
      postTags.length > 0 && (
        <div class="mt-6 flex flex-wrap gap-2">
          {postTags.map((t) => (
            <a href={tagPath(t.slug)} class="rounded-control border border-border px-3 py-1 text-xs font-medium text-muted-foreground no-underline">
              {t.label}
            </a>
          ))}
        </div>
      )
    }
    {
      related.length > 0 && (
        <aside class="mt-14">
          <h2 class="mb-6 text-lg font-semibold">Related posts</h2>
          <div class="grid grid-cols-1 gap-8 sm:grid-cols-3">
            {related.map((p) => (
              <PostCard post={p} />
            ))}
          </div>
        </aside>
      )
    }
    {
      post.commentingEnabled && post.referenceId && (
        <div class="mt-14">
          <CommentsView client:load referenceId={post.referenceId} />
        </div>
      )
    }
  </article>
</SiteLayout>

SHA-256: 468756b8ca3c1ea95bca2298302e86896703bdcc0985ff70a77f58a01369414e