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
categoriestable; 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
subscriberstable 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
poststable - Article body -- rendered HTML content from the
contentcolumn, styled with Tailwind Typography (proseclasses) - View count increment -- on page load, the
increment_post_viewsRPC function is called to safely increment theviewscolumn without requiring authentication - Tags -- tag pills rendered from
post_tagsjoin, 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
commentstable, threaded viaparent_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
poststable wherestatus = 'published', grouped bycategory_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)andstatus = '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_tagsjunction 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
profilestable - Published posts -- paginated list of the author's published posts, fetched with
.eq("author_id", authorId)andstatus = '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' |
Search
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
ftstsvector column onposts - 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
profilestable, 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
subscriberstable 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_settingstable - 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_atorpublished_atcolumns - 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
- Admin CMS -- content management dashboard and editorial tools
- Server Actions -- complete reference for all data operations
- Database Schema -- table definitions and RLS policies