Remember the old ritual?
- Create
app/api/something/route.ts - Write a POST handler
- Parse the request
- Validate the data
- Hit the database
- Return
Response.json() - Write a client-side fetch function
- Handle loading/error states
- Repeat for every. single. feature.
I did this for years. Until last month.
I was building an IELTS mock test platform (think user registration, test submissions, score calculations). I had API routes everywhere. Then I discovered Server Actions in Next.js, and honestly? It felt like cheating.
Here's what I learned.
Wait, What Actually Is a Server Action?
A Server Action is a function that runs exclusively on the server but can be called directly from your client components. No API routes. No fetch calls. No Response.json() boilerplate.
Think of it as: "I want this function to run on the server, but I want to call it like a normal function from my button click."
Introduced in Next.js 14 and matured in Next.js 15, Server Actions are now stable, production-ready, and honestly? Game-changing.
The "Before" Nightmare (What We Used to Do)
Here's how I handled a simple contact form before Server Actions:
Step 1: Create an API route
// app/api/contact/route.ts
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
export async function POST(request: Request) {
try {
const body = await request.json()
const { name, email, message } = body
// Validate
if (!name || !email || !message) {
return NextResponse.json(
{ error: 'Missing fields' },
{ status: 400 }
)
}
// Save to database
await db.insert({
name,
email,
message
})
return NextResponse.json(
{ success: true },
{ status: 200 }
)
} catch (error) {
return NextResponse.json(
{ error: 'Something went wrong' },
{ status: 500 }
)
}
}Step 2: Write the client-side code
// components/ContactForm.tsx
'use client'
import { useState } from 'react'
export function ContactForm() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setLoading(true)
const formData = new FormData(e.currentTarget)
try {
const res = await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message')
}),
headers: {
'Content-Type': 'application/json'
}
})
if (!res.ok) throw new Error('Failed')
// Handle success
} catch (err) {
setError('Something went wrong')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
</form>
)
}That's 50+ lines of code for a simple form. And I haven't even shown the TypeScript interfaces, error boundaries, or loading skeletons.
The "After" Beauty (Server Actions)
Here's the same feature with Server Actions:
// app/actions/contact.ts
'use server'
import { db } from '@/lib/db'
import { z } from 'zod'
// 1. Define validation (optional but recommended)
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10)
})
// 2. This is your Server Action
export async function submitContact(formData: FormData) {
// 3. Parse the FormData
const name = formData.get('name') as string
const email = formData.get('email') as string
const message = formData.get('message') as string
// 4. Validate
const validated = contactSchema.safeParse({ name, email, message })
if (!validated.success) {
return {
error: 'Invalid input',
issues: validated.error.issues
}
}
// 5. Save to database
await db.insert({
name,
email,
message
})
// 6. Return something (this becomes the response)
return { success: true }
}And the client component:
// components/ContactForm.tsx
'use client'
import { useRef } from 'react'
import { submitContact } from '@/app/actions/contact'
export function ContactForm() {
const ref = useRef<HTMLFormElement>(null)
async function handleSubmit(formData: FormData) {
// Call the Server Action DIRECTLY
const result = await submitContact(formData)
if (result.error) {
alert(result.error)
return
}
ref.current?.reset()
alert('Message sent!')
}
return (
<form
ref={ref}
action={handleSubmit}
className="space-y-4"
>
<input
name="name"
placeholder="Your name"
required
/>
<input
name="email"
type="email"
placeholder="Your email"
required
/>
<textarea
name="message"
placeholder="Your message"
required
/>
<button type="submit">
Send Message
</button>
</form>
)
}That's it. No API routes. No fetch. No JSON parsing. No loading state boilerplate (though you can add it with useTransition).
Why This Matters (The Real Benefits)
1. Type Safety Without Extra Work
In the API route approach, your frontend and backend types can drift. One change breaks everything silently.
With Server Actions, it's just functions. If you change the return type, TypeScript yells at you immediately.
2. Progressive Enhancement For Free
Look at the form above. If JavaScript fails to load (rare, but happens), the form still works. The action={handleSubmit} syntax means the form submits natively. JavaScript enhances it, not powers it.
3. No API Route Sprawl
I deleted 12 API route files from my IELTS project. Twelve. Each file was 30–60 lines. That's ~500 lines of boilerplate gone.
4. Direct Database Access
Before, I had to authenticate, authorize, connect to the DB, and handle errors — all inside the API route. Now, I just write a function that runs on the server. If I'm already authenticated (via middleware or session), the Server Action inherits that context.
The "Gotchas" (What Nobody Tells You)
I'm not here to sell you a dream. Server Actions have quirks:
1. They're Mutations, Not Data Fetching
Server Actions are for changing data, not fetching it. For data fetching, stick to Server Components or Route Handlers.
Don't do this:
'use server'
export async function getUsers() { // ❌ Bad
return await db.select().from(users)
}Do this:
// In your Server Component
async function UsersPage() {
const users = await db.select().from(users) // ✅ Good
return <UserList users={users} />
}2. Loading States Require useTransition
The example above didn't show loading states. Here's how:
'use client'
import { useTransition } from 'react'
import { submitContact } from '@/app/actions/contact'
export function ContactForm() {
const [isPending, startTransition] = useTransition()
async function handleSubmit(formData: FormData) {
startTransition(async () => {
const result = await submitContact(formData)
// handle result
})
}
return (
<form action={handleSubmit}>
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send Message'}
</button>
</form>
)
}3. They Run on the Server (Duh)
This sounds obvious, but it means:
- You can't use
localStorage - You can't access
window - You can't use browser APIs
But you can:
- Access your database directly
- Use server-only packages
- Keep secrets safe
Real-World Example: My IELTS Platform
Here's how I'm using Server Actions in production:
// app/actions/auth.ts
'use server'
import { db } from '@/lib/db'
import { hashPassword } from '@/lib/auth'
import { users, sessions } from '@/lib/schema'
import { cookies } from 'next/headers'
export async function registerUser(formData: FormData) {
const email = formData.get('email') as string
const password = formData.get('password') as string
const name = formData.get('name') as string
// Check if user exists
const existing = await db.query.users.findFirst({
where: (users, { eq }) => eq(users.email, email)
})
if (existing) {
return { error: 'User already exists' }
}
// Hash password
const hashedPassword = await hashPassword(password)
// Create user
const [newUser] = await db.insert(users).values({
email,
name,
hashedPassword
}).returning()
// Create session
const sessionId = crypto.randomUUID()
await db.insert(sessions).values({
id: sessionId,
userId: newUser.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
})
cookies().set('session', sessionId, {
httpOnly: true,
secure: true,
sameSite: 'lax'
})
return { success: true }
}One function. Registration complete. No API routes.
When Not to Use Server Actions
Let's be balanced. Here's when I still use API routes:
| Scenario | Why API Routes Win | | --- | --- | | Public REST APIs | You need standard endpoints for third parties | | File downloads | Streaming responses are easier with API routes | | Webhooks | Incoming webhooks need stable, documented endpoints | | Heavy computation | You might want to offload to background jobs |
Server Actions are for your own frontend talking to your own backend. For everything else, API routes still have their place.
The Mental Shift
The biggest change for me wasn't technical. It was mental.
I stopped thinking in terms of "endpoints" and started thinking in terms of "actions."
- Instead of "I need a POST /api/contact endpoint" → I thought "I need a
submitContactaction" - Instead of "I need to fetch user data" → I thought "This is a Server Component, I'll just query the DB"
- Instead of "client vs server boundaries" → I thought "data flows down, actions flow up"
This shift made my code simpler, my mental model cleaner, and my development faster.
Your Turn
If you're still writing API routes for every form submission, every button click, every database mutation — try Server Actions for one feature.
Take a simple contact form, or a newsletter signup, or a comment box. Convert it. Feel the difference.
Then come back and tell me you don't feel like you've been cheating all along.
Resources
I'm building an IELTS mock test platform with Next.js, TypeScript, and PostgreSQL. Follow me for more deep dives into modern web development.