Every mutation in the kit uses Next.js Server Actions. They run server-side, use the appropriate Supabase client for reads and writes, and call revalidatePath() to refresh page data.
Architecture
Client Component --> Server Action --> Supabase Client --> Database
--> revalidatePath() --> Page Refresh
All Server Actions are in lib/actions/ and marked with "use server" at the top of each file.
lib/actions/
├── auth.ts 5 actions Authentication
├── posts.ts 12 actions Post CRUD + search
├── categories.ts 5 actions Category CRUD
├── tags.ts 5 actions Tag CRUD
├── authors.ts 3 actions Author management
├── comments.ts 6 actions Comments + moderation
├── media.ts 4 actions Media library + storage
├── subscribers.ts 5 actions Newsletter subscribers
├── analytics.ts 5 actions Dashboard + analytics
└── settings.ts 2 actions Site settings
Auth Actions (lib/actions/auth.ts)
| Action |
Parameters |
Description |
signIn |
email, password |
Sign in with email/password; redirects to /admin |
signUp |
email, password, fullName |
Create account; triggers profile creation with role = 'author' |
signOut |
- |
Clear session and redirect to /login |
resetPassword |
email |
Send password reset email |
signInWithProvider |
provider |
Redirect to OAuth provider (Google, GitHub) |
Post Actions (lib/actions/posts.ts)
| Action |
Parameters |
Description |
getPosts |
filters, search, page, limit |
Paginated list with status, category, and author filters |
getPost |
id |
Single post with category, tags, and author joins |
getPostBySlug |
slug |
Public post lookup by slug; only returns published posts |
getPublishedPosts |
page, limit, categoryId |
Paginated published posts for public blog |
getPostsByAuthor |
authorId, page, limit |
Published posts by a specific author |
getRelatedPosts |
postId, categoryId, limit |
Posts in the same category, excluding the current post |
getScheduledPosts |
- |
All posts with status = 'scheduled' |
createPost |
FormData |
Insert post, post_tags; auto-calculates reading_time via trigger |
updatePost |
id, FormData |
Update post details; authors can only update own posts |
deletePost |
id |
Delete post and associated post_tags (editor+ only) |
incrementPostViews |
slug |
Call increment_post_views RPC (public, no auth required) |
searchPosts |
query, categoryId |
Full-text search via fts tsvector column |
Post Query Example
// getPublishedPosts (simplified)
const { data, count } = await supabase
.from("posts")
.select("*, categories(*), profiles(*), post_tags(tags(*))", { count: "exact" })
.eq("status", "published")
.order("published_at", { ascending: false })
.range(offset, offset + limit - 1)
Category Actions (lib/actions/categories.ts)
| Action |
Parameters |
Description |
getCategories |
- |
All categories with post counts |
getCategoryBySlug |
slug |
Single category by slug |
createCategory |
name, slug, description, color, image |
Create category (admin only) |
updateCategory |
id, data |
Update category details (admin only) |
deleteCategory |
id |
Delete category; unlinks posts (admin only) |
| Action |
Parameters |
Description |
getTags |
- |
All tags with post counts |
getTagBySlug |
slug |
Single tag by slug with post count |
createTag |
name, slug |
Create tag (author+ can create) |
updateTag |
id, name, slug |
Update tag details |
deleteTag |
id |
Delete tag and post_tags associations (admin only) |
Author Actions (lib/actions/authors.ts)
| Action |
Parameters |
Description |
getAuthors |
- |
All profiles with published post counts |
getAuthorById |
id |
Single author profile with posts |
updateAuthor |
id, data |
Update author profile (own profile or admin) |
| Action |
Parameters |
Description |
getComments |
filters, page |
Paginated comments with status filter |
getCommentsByPost |
postId |
Approved comments for a public post page |
getPendingComments |
- |
All pending comments for moderation queue |
submitComment |
postId, authorName, authorEmail, content, parentId |
Public comment submission (no auth); creates as pending |
moderateComment |
id, status |
Change status to approved, pending, or spam (editor+ only) |
deleteComment |
id |
Permanently delete comment (editor+ only) |
// submitComment -- no auth required, uses anon-compatible query
export async function submitComment(data: CommentInput) {
try {
const supabase = await createServerClient()
const { error } = await supabase.from("comments").insert({
post_id: data.postId,
author_name: data.authorName,
author_email: data.authorEmail,
content: data.content,
status: "pending",
parent_id: data.parentId || null,
})
if (error) throw error
return { success: true, message: "Comment submitted for review" }
} catch (err) {
console.error("Failed to submit comment:", err)
return { success: false, error: "Failed to submit comment" }
}
}
| Action |
Parameters |
Description |
getMediaItems |
page, limit |
Paginated media library items |
uploadMedia |
File |
Upload to Supabase Storage blog-assets bucket; insert media row |
updateMediaAlt |
id, altText |
Update alt text for accessibility |
deleteMedia |
id |
Delete from Storage and media table (editor+ only) |
Upload Implementation
export async function uploadMedia(formData: FormData) {
try {
const supabase = createAdminClient()
const file = formData.get("file") as File
const path = `uploads/${Date.now()}-${file.name}`
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from("blog-assets")
.upload(path, file)
if (uploadError) throw uploadError
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from("blog-assets")
.getPublicUrl(path)
// Insert media record
const { error: insertError } = await supabase.from("media").insert({
filename: file.name,
url: publicUrl,
storage_path: path,
file_size: file.size,
uploaded_by: userId,
})
if (insertError) throw insertError
revalidatePath("/admin/media")
return { success: true, url: publicUrl }
} catch (err) {
console.error("Failed to upload media:", err)
return { success: false, error: "Failed to upload media" }
}
}
Subscriber Actions (lib/actions/subscribers.ts)
| Action |
Parameters |
Description |
getSubscribers |
search, page, limit |
Paginated subscriber list with search |
subscribe |
email, source |
Public newsletter signup (no auth); respects unique constraint |
unsubscribe |
email |
Set subscriber status to unsubscribed |
addSubscriber |
email, source |
Manual subscriber addition (editor+ only) |
deleteSubscriber |
id |
Remove subscriber record (editor+ only) |
Public Subscribe
// subscribe -- no auth required, uses anon-compatible query
export async function subscribe(email: string, source: string = "homepage") {
try {
const supabase = await createServerClient()
const { error } = await supabase.from("subscribers").insert({
email,
source,
status: "active",
})
if (error) {
if (error.code === "23505") {
return { success: true, message: "You're already subscribed!" }
}
throw error
}
return { success: true, message: "Successfully subscribed!" }
} catch (err) {
console.error("Failed to subscribe:", err)
return { success: false, error: "Failed to subscribe" }
}
}
Analytics Actions (lib/actions/analytics.ts)
| Action |
Parameters |
Description |
getDashboardStats |
- |
KPI aggregates: total posts, views, subscribers, pending comments |
getPageviewData |
dateRange |
Daily views and unique visitors from pageview_daily |
getPostAnalytics |
limit |
Top posts by views with category and author |
getSubscriberGrowth |
dateRange |
Cumulative subscriber count over time |
getActivityFeed |
limit |
Recent content activity across posts, comments, subscribers |
Dashboard Stats Query
export async function getDashboardStats() {
const supabase = await createServerClient()
const [posts, views, subscribers, pendingComments] = await Promise.all([
supabase.from("posts").select("*", { count: "exact", head: true })
.eq("status", "published"),
supabase.from("posts").select("views").eq("status", "published"),
supabase.from("subscribers").select("*", { count: "exact", head: true })
.eq("status", "active"),
supabase.from("comments").select("*", { count: "exact", head: true })
.eq("status", "pending"),
])
const totalViews = views.data?.reduce((sum, p) => sum + (p.views || 0), 0) ?? 0
return {
totalPosts: posts.count ?? 0,
totalViews,
activeSubscribers: subscribers.count ?? 0,
pendingComments: pendingComments.count ?? 0,
}
}
Settings Actions (lib/actions/settings.ts)
| Action |
Parameters |
Description |
getSiteSettings |
- |
Get the singleton site settings row |
updateSiteSettings |
data |
Update site settings (admin only) |
Error Handling Pattern
All Server Actions follow this pattern:
"use server"
import { createAdminClient } from "@/lib/supabase/admin"
import { revalidatePath } from "next/cache"
export async function updatePost(id: string, formData: FormData) {
try {
const supabase = createAdminClient()
const { error } = await supabase
.from("posts")
.update({
title: formData.get("title"),
content: formData.get("content"),
// ...
})
.eq("id", id)
if (error) throw error
revalidatePath("/admin/posts")
revalidatePath(`/admin/posts/${id}/edit`)
return { success: true }
} catch (err) {
console.error("Failed to update post:", err)
return { success: false, error: "Failed to update post" }
}
}
Every action returns { success: boolean, error?: string } so client components can display toast notifications consistently.
Using Server Actions in Components
"use client"
import { moderateComment } from "@/lib/actions/comments"
function CommentCard({ comment }: Props) {
const [isPending, startTransition] = useTransition()
function handleApprove() {
startTransition(async () => {
const result = await moderateComment(comment.id, "approved")
if (result.success) {
toast.success("Comment approved")
} else {
toast.error(result.error)
}
})
}
return (
<Card>
<p>{comment.content}</p>
<Button onClick={handleApprove} disabled={isPending}>
Approve
</Button>
</Card>
)
}
Supabase Client Selection
| Context |
Client |
RLS |
Reason |
| Public reads (blog pages) |
Server client |
Yes (anon) |
RLS returns only published posts, approved comments |
| Admin reads (dashboard) |
Server client |
Yes (user session) |
RLS scoped to authenticated user's role |
| Write operations (create, update) |
Admin client |
Bypassed |
Service role ensures writes succeed regardless of RLS |
| Delete operations |
Admin client |
Bypassed |
Service role for consistent deletion |
| Public comment submission |
Server client |
Yes (anon) |
RLS allows anonymous insert with pending status |
| Public newsletter signup |
Server client |
Yes (anon) |
RLS allows anonymous insert into subscribers |
| View count increment |
Server client |
N/A |
Calls RPC function with SECURITY DEFINER |
| Auth operations |
Server client |
N/A |
Supabase Auth API |
Why Two Clients?
- Server client (with RLS): Used for reads where you want the database to enforce access rules. Public blog routes use anonymous access; admin routes use the authenticated session. Also used for public writes (comments, subscribers) where the RLS policy explicitly allows anonymous inserts.
- Admin client (bypasses RLS): Used for authenticated write operations (creating posts, moderating comments, uploading media) where the Server Action has already verified the user's role in application code. This avoids the need for complex RLS write policies while keeping the security boundary in the Server Action layer.
The admin client (lib/supabase/admin.ts) uses SUPABASE_SERVICE_ROLE_KEY and should never be exposed to the client.