Skip to content

Blog System

The marketing blog reads published posts, authors, and categories from Sanity. Next.js Server Components fetch the content; focused client components handle interactive search, filters, sorting, pagination, sharing, and table-of-contents behavior.

Data Flow

text
Sanity documents
  -> parameterized GROQ in src/utils/blog/
  -> resource result { data, error }
  -> App Router Server Component
  -> blog presentation components
  -> client search/filter/page state in the URL

The Sanity schemas in src/sanity/schemaTypes/ define:

  • posts with title, slug, author, main image, categories, publication date, and Portable Text body;
  • authors with a name, slug, image, and Portable Text bio;
  • categories with a title, slug, and description.

See Content Management for who edits code-driven content, CMS documents, schemas, and Studio configuration.

Route Family

Blog routes live under apps/marketing/src/app/(main)/blog/:

text
blog/
├── page.tsx
├── [slug]/page.tsx
├── author/page.tsx
├── author/[slug]/page.tsx
├── category/page.tsx
└── category/[slug]/page.tsx

The route family also defines loading states, blog not-found UI, and sitemap modules for posts, authors, and categories.

Listing Route

The main listing fetches posts and categories, handles either resource error, and passes both collections into BlogPageClient inside BlogSuspense:

tsx
const getAllCategoriesCached = cache(() => getAllCategoriesResource())
const getAllPostsCached = cache(() => getAllPostsResource())

const BlogPage = async () => {
  const { data: posts, error: postsError } = await getAllPostsCached()
  const { data: categories, error: categoriesError } = await getAllCategoriesCached()

  if (postsError || categoriesError) return <PageContainer variant="centered">...</PageContainer>

  return (
    <BlogSuspense fallback={<div>{strings.blog.page.loading.posts}</div>}>
      <BlogPageClient posts={posts} categories={categories} />
    </BlogSuspense>
  )
}

The omitted error content uses the route's real PageContainer, title, and description strings. React cache() lets metadata generation and rendering reuse matching work during a request. It does not configure persistent route revalidation.

BlogPageClient keeps search, category, and page in URL search parameters. It filters the server-fetched collection in memory and renders six posts per page. Search debounces text changes before updating the URL. Author and category listing clients use the same server-fetch/client-filter split for their own search and sort controls.

Post Route

blog/[slug]/page.tsx fetches the post by a parameterized slug and calls notFound() when the resource is missing or fails. It derives the publication display date, reading-time estimate, table of contents, metadata excerpt, and recent-post list before composing:

  • BlogHeader for author, date, sharing, and title;
  • BlogHero for the post's required Sanity main image;
  • BlogBody for Portable Text;
  • BlogTableOfContents from Portable Text headings;
  • BlogCategories and BlogRelatedPosts for navigation;
  • page-specific metadata and JSON-LD.

Author and category detail routes fetch their document and related posts through the matching resource modules in src/utils/blog/.

Resource Layer

src/utils/blog/posts.ts, authors.ts, and categories.ts contain GROQ queries and convert fetch failures into { data, error } results. Query values such as slugs, limits, and excluded IDs are passed as GROQ parameters. Category slugs are normalized with sanitizeSlug() before querying.

The Sanity client in src/sanity/lib/client.ts reads the configured project, dataset, API version, and token and sets useCdn: true for published reads. The current blog does not export a revalidate interval, use cache tags, or implement draft preview.

Portable Text And Images

BlogBody renders structured Portable Text through @portabletext/react; it does not inject CMS HTML. Inline content images use urlFor() from src/sanity/lib/image.ts to request a bounded Sanity CDN image before rendering with Next.js Image.

Post cards and heroes prefer Sanity alt text and use the post title or article-image copy as a fallback. Portable Text images without alt text use an empty alt value. Author avatars derive their accessible name and fallback initials from the author's name. Category cards do not render content images.

Metadata And Sitemaps

Blog routes use the shared metadata and JSON-LD helpers. The post route passes its fetched post into getPageMetadata() and getPageSchemaGraph(). Sitemap modules use the same blog resource layer to produce URLs for published posts, authors, and categories. See SEO and Schema for the shared metadata contract.

Changing The Blog

When a blog field changes, update the connected layers together:

  1. Change the relevant schema in src/sanity/schemaTypes/.
  2. Update the TypeScript type and GROQ projections that read the field.
  3. Update route metadata or sitemap derivation if the field affects discoverability.
  4. Update the presentation component and its loading, missing-data, and accessible-image behavior.
  5. Test listing, detail, author, and category routes against representative published content.

Built with ❤️ by the Jubiloop team