Server Actions

Last updated on 2026-09-03

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)

Tag Actions (lib/actions/tags.ts)

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)

Comment Actions (lib/actions/comments.ts)

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)

Public Comment Submission

// 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" }
  }
}

Media Actions (lib/actions/media.ts)

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.