Public Blog

Last updated on 2026-09-03

The public blog is fully accessible without authentication. All data is fetched server-side from Supabase using the server client with anonymous access -- RLS policies ensure only published posts and approved comments are returned. Public visitors can submit comments and subscribe to the newsletter without logging in.

Home Page

Route: /

The blog landing page with a featured posts section and paginated post list. All data comes from the posts table with joins to categories and profiles.

  • Featured posts -- hero section showcasing the most recent published posts with featured images, category badges, author avatars, and reading time
  • Category filter -- horizontal pill navigation querying categories table; selecting a category filters the post list via Supabase .eq("category_id", id)
  • Post list -- paginated grid of post cards, each showing featured image, category badge, title, excerpt, author name with avatar, published date, and reading time
  • Pagination -- server-side pagination with "Load more" or numbered pages; queries Supabase with .range(offset, offset + limit - 1)
  • Newsletter CTA -- inline subscription form that writes to the subscribers table via Server Action

Data Sources

Data Source Query
Featured posts posts table status = 'published', ordered by published_at, limit 3-5
Post list posts table status = 'published', with category and author joins, paginated
Categories categories table All categories for filter pills

Article Page

Route: /posts/[slug]

The full article page with content rendering, view tracking, comments, and related posts. Data is fetched server-side by slug.

  • Article header -- category badge (linked), title (H1), excerpt, author card (avatar, name, bio -- linked to author page), published date, reading time, and view count
  • Featured image -- full-width hero image with alt text from the posts table
  • Article body -- rendered HTML content from the content column, styled with Tailwind Typography (prose classes)
  • View count increment -- on page load, the increment_post_views RPC function is called to safely increment the views column without requiring authentication
  • Tags -- tag pills rendered from post_tags join, each linking to /tags/[slug]
  • Author bio -- expanded author card at the end of the article with full bio, social links, and link to the author's post archive
  • Comments section -- displays approved comments from the comments table, threaded via parent_id; includes a public submission form
  • Related posts -- 3-4 posts from the same category, fetched via Supabase query

View Count Implementation

// Called on article page load (Server Action)
await supabase.rpc("increment_post_views", { post_slug: slug })

The RPC function uses SECURITY DEFINER to bypass RLS, allowing anonymous page views to increment the counter.

Comment Submission

Public visitors can submit comments without authentication:

// Public comment submission (Server Action)
const { error } = await supabase.from("comments").insert({
  post_id: postId,
  author_name: name,
  author_email: email,
  content: content,
  status: "pending", // Always pending until moderated
  parent_id: parentId || null, // For threaded replies
})

Comments are created with status = 'pending' and only appear publicly after an editor or admin approves them in the admin CMS.

Data Sources

Data Source Query
Post posts table slug match, status = 'published', with category and author joins
Tags post_tags + tags Join on post_id
Comments comments table post_id match, status = 'approved', ordered by created_at
Related posts posts table Same category_id, exclude current post, limit 4

Categories Index

Route: /categories

A grid of all categories with post counts.

  • Category grid -- cards showing category name, color accent, description, image, and post count
  • Post counts -- aggregated from posts table where status = 'published', grouped by category_id
  • Links -- each card links to /categories/[slug]

Aggregate Query

// Category with post count
const { data } = await supabase
  .from("categories")
  .select("*, posts(count)")
  .eq("posts.status", "published")

Category Posts

Route: /categories/[slug]

Posts filtered by a specific category.

  • Category header -- category name, description, color accent, and image
  • Post list -- paginated grid of posts in this category, fetched with .eq("category_id", categoryId) and status = 'published'
  • Post count -- total published posts in this category

Tag Posts

Route: /tags/[slug]

Posts filtered by a specific tag.

  • Tag header -- tag name and post count
  • Post list -- paginated grid of posts with this tag, fetched via the post_tags junction table
  • Related tags -- other tags that commonly appear alongside this one

Tag Query

// Posts by tag via junction table
const { data } = await supabase
  .from("posts")
  .select("*, categories(*), profiles(*), post_tags!inner(tag_id)")
  .eq("post_tags.tag_id", tagId)
  .eq("status", "published")
  .order("published_at", { ascending: false })

Author Profile

Route: /authors/[slug]

Public author profile page with their published posts.

  • Author header -- avatar, full name, bio, and social links (Twitter, GitHub, LinkedIn, website) from the profiles table
  • Published posts -- paginated list of the author's published posts, fetched with .eq("author_id", authorId) and status = 'published'
  • Post count -- total published posts by this author
  • Stats -- total views across all published posts

Data Sources

Data Source Query
Author profile profiles table Matched by slug derived from name
Author's posts posts table author_id match, status = 'published'

Route: /search

Full-text search across all published posts with category filtering.

  • Search input -- text input that triggers a full-text search query against the fts tsvector column on posts
  • Category filter -- optional category dropdown to narrow results
  • Results list -- matching posts displayed as cards with highlighted relevance
  • Empty state -- helpful message when no results match

Search Implementation

// Full-text search with optional category filter
let query = supabase
  .from("posts")
  .select("*, categories(*), profiles(*)")
  .eq("status", "published")
  .textSearch("fts", searchQuery, { type: "websearch" })
  .order("published_at", { ascending: false })

if (categoryId) {
  query = query.eq("category_id", categoryId)
}

About

Route: /about

Team page displaying all blog authors and editors.

  • Team grid -- cards for each author/editor from the profiles table, showing avatar, name, role badge, bio, and social links
  • Ordered by role -- admins first, then editors, then authors

Newsletter Archive

Route: /newsletter

Archive of past newsletter issues from the newsletter_issues table.

  • Issue list -- chronological list of sent newsletters with title, description, sent date, and recipient count
  • Subscribe CTA -- newsletter signup form that writes to the subscribers table via Server Action

Newsletter Signup

// Public newsletter subscription (Server Action)
const { error } = await supabase.from("subscribers").insert({
  email: email,
  source: "newsletter",
  status: "active",
})

The email column has a unique constraint -- duplicate signups return a friendly "already subscribed" message rather than an error.

RSS Feed

Route: /rss

RSS 2.0 feed generated from published posts.

  • Feed generation -- queries the 20 most recent published posts from Supabase and generates an XML RSS feed
  • Includes -- title, description, link, published date, author, and category for each post
  • Blog metadata -- blog name and description from site_settings table
  • Content-Type -- served as application/rss+xml

Sitemap Preview

Route: /sitemap-preview

Displays the sitemap data in a human-readable format.

  • URL list -- all published post URLs, category pages, tag pages, and author pages
  • Last modified dates -- from the updated_at or published_at columns
  • Useful for debugging -- verifies which URLs will appear in the generated sitemap

Key Public Interactions

The blog has three main public interactions that write to the database without authentication:

Interaction Table Status Moderation
Comment submission comments Created as pending Editor approves in admin CMS
Newsletter signup subscribers Created as active Editor can remove in admin
View counting posts.views Incremented via RPC No moderation needed

All three use RLS policies that allow anonymous inserts (comments, subscribers) or SECURITY DEFINER functions (view counting) to work without authentication.

Next Steps