Problem: Setting Up Next.js 16 Server Actions Takes Too Long
You want to build a Next.js app with Server Actions but manual setup involves configuring TypeScript, handling form validation, managing loading states, and debugging client/server boundaries - easily 2+ hours of work.
You'll learn:
- How to use Cursor's Composer to scaffold a complete Next.js 16 app
- Server Actions implementation with proper error handling
- AI-assisted debugging of client/server boundary issues
Time: 25 min | Level: Intermediate
Why This Approach Works
Cursor's AI understands Next.js conventions and can generate type-safe Server Actions that follow React 19 patterns. Instead of reading docs and writing boilerplate, you describe what you want and iterate on working code.
What you get:
- Type-safe Server Actions with Zod validation
- Proper
useFormStatusintegration - Error boundaries and loading states
- Production-ready patterns, not tutorials
Prerequisites
# Verify installations
node --version # Need 20.x or higher
npm --version # Need 10.x or higher
cursor --version # Need Cursor 0.42 or higher
If Cursor isn't installed: Download from cursor.com
Solution
Step 1: Initialize Project with Cursor
Open Cursor and press Cmd/Ctrl + I to open Composer:
Prompt to use:
Create a new Next.js 16 app called "task-manager" with:
- App router
- TypeScript
- Tailwind CSS
- Server Actions for creating tasks
- Zod for validation
- SQLite with better-sqlite3
Set up the project structure with proper folders.
What Cursor will do:
- Run
npx create-next-app@latest - Install dependencies (zod, better-sqlite3)
- Create
/app/actions/folder structure - Set up basic database schema
Expected output:
✔ Creating task-manager...
✔ Installing dependencies...
✔ Created app/actions/tasks.ts
✔ Created lib/db.ts
If it fails:
- "Command not found": Cursor needs Terminal access - grant permissions in Settings
- Dependency errors: Tell Cursor "Fix the peer dependency warnings"
Step 2: Build the Server Action
In Composer, prompt:
Create a Server Action in app/actions/tasks.ts that:
- Validates input with Zod (title: string, priority: "low" | "medium" | "high")
- Inserts into SQLite database
- Returns success/error response
- Uses proper Next.js 16 patterns
Why this works: Cursor knows Next.js conventions and will add 'use server' directive, proper error handling, and type-safe returns.
Generated code preview:
'use server'
import { z } from 'zod';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
const taskSchema = z.object({
title: z.string().min(1, 'Title required').max(100),
priority: z.enum(['low', 'medium', 'high']),
});
export async function createTask(formData: FormData) {
// Cursor adds validation automatically
const validated = taskSchema.safeParse({
title: formData.get('title'),
priority: formData.get('priority'),
});
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors };
}
try {
// Proper SQLite handling
db.prepare('INSERT INTO tasks (title, priority) VALUES (?, ?)')
.run(validated.data.title, validated.data.priority);
revalidatePath('/'); // Next.js 16 cache invalidation
return { success: true };
} catch (error) {
return { error: { _form: ['Database error'] } };
}
}
What Cursor added:
'use server'directive (required for Server Actions)safeParseinstead ofparse(better error handling)revalidatePath(updates UI automatically)- Proper FormData extraction
Step 3: Create the Form Component
Prompt Cursor:
Create a client component app/components/TaskForm.tsx that:
- Uses the createTask Server Action
- Shows loading state with useFormStatus
- Displays validation errors inline
- Resets form on success
Cursor generates:
'use client'
import { useFormState, useFormStatus } from 'react-dom';
import { createTask } from '@/app/actions/tasks';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 disabled:bg-gray-400"
>
{pending ? 'Creating...' : 'Create Task'}
</button>
);
}
export function TaskForm() {
const [state, formAction] = useFormState(createTask, null);
return (
<form action={formAction} className="space-y-4">
<div>
<input
name="title"
placeholder="Task title"
className="border rounded px-3 py-2"
/>
{state?.error?.title && (
<p className="text-red-600 text-sm">{state.error.title[0]}</p>
)}
</div>
<select name="priority" className="border rounded px-3 py-2">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<SubmitButton />
{state?.success && (
<p className="text-green-600">Task created!</p>
)}
</form>
);
}
Why separate SubmitButton: useFormStatus only works in children of <form>, Cursor knows this pattern.
Step 4: Debug with Cursor Chat
Run the app:
npm run dev
Common issue: Form submits but doesn't reset.
Ask Cursor in Chat (Cmd/Ctrl + L):
The form doesn't reset after successful submission. Fix it.
Cursor will suggest:
// Add useEffect to reset
import { useEffect, useRef } from 'react';
export function TaskForm() {
const [state, formAction] = useFormState(createTask, null);
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state?.success) {
formRef.current?.reset();
}
}, [state?.success]);
return (
<form ref={formRef} action={formAction}>
{/* ... */}
</form>
);
}
Why this pattern: Server Actions don't auto-reset forms, you need controlled reset.
Step 5: Add Real-time Validation
Ask Cursor:
Add client-side validation that shows errors as user types, before submission.
Cursor adds:
import { useState } from 'react';
export function TaskForm() {
const [clientErrors, setClientErrors] = useState<Record<string, string>>({});
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
if (value.length === 0) {
setClientErrors(prev => ({ ...prev, title: 'Title required' }));
} else if (value.length > 100) {
setClientErrors(prev => ({ ...prev, title: 'Max 100 characters' }));
} else {
setClientErrors(prev => {
const { title, ...rest } = prev;
return rest;
});
}
};
return (
<form action={formAction}>
<input
name="title"
onChange={handleTitleChange}
className="border rounded px-3 py-2"
/>
{(clientErrors.title || state?.error?.title) && (
<p className="text-red-600 text-sm">
{clientErrors.title || state.error.title[0]}
</p>
)}
{/* ... */}
</form>
);
}
What changed: Cursor duplicated Zod validation logic to client-side for immediate feedback.
Verification
Test the complete flow:
npm run dev
You should see:
- Form renders with Tailwind styling
- Type invalid title → error appears instantly
- Submit → button shows "Creating..."
- Success → "Task created!" message
- Form resets automatically
Database check:
# In new terminal
npx better-sqlite3 tasks.db "SELECT * FROM tasks"
Should show your created task with title and priority.
Cursor Power Moves
Multi-file Editing
Press Cmd/Ctrl + Shift + I for Composer with file context:
Update both TaskForm and createTask to add a "due_date" field with proper validation
Cursor edits both files atomically.
Error Fixing
When you see a TypeScript error, select it and press Cmd/Ctrl + K:
Fix this type error
Cursor understands the surrounding context.
Code Explanation
Select any code block, right-click → "Cursor: Explain":
- Why
'use server'is needed - How
useFormStatustracks pending state - What
revalidatePathdoes
What You Learned
- Cursor scaffolds full features faster than manual coding
- Server Actions need
'use server'and proper error handling useFormStatusmust be in form children (common gotcha)- Client + server validation prevents bad UX and bad data
Limitations:
- Cursor sometimes over-engineers (review generated code)
- Doesn't handle complex database migrations (use Drizzle/Prisma)
- AI can suggest outdated patterns (verify with Next.js docs)
Troubleshooting Guide
"Server Actions are not enabled"
Fix: Update next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
};
module.exports = nextConfig;
Tell Cursor: "Enable Server Actions in next config"
Form submits twice
Cause: React 18+ Strict Mode calls functions twice in development.
Fix: Normal behavior, won't happen in production. Or disable:
// next.config.js
reactStrictMode: false, // Not recommended
Cursor suggests outdated code
Example: Uses getServerSideProps instead of Server Components.
Fix: Be explicit in prompts:
Use Next.js 16 App Router patterns, not Pages Router
Or add to Cursor Rules (.cursorrules file):
Always use Next.js 16 App Router conventions:
- Server Components by default
- Server Actions for mutations
- No getServerSideProps
Production Checklist
Before deploying:
- Replace SQLite with PostgreSQL (Cursor: "Migrate to Postgres with Prisma")
- Add rate limiting to Server Actions
- Implement proper error logging (Sentry)
- Add loading skeletons (Cursor: "Add Suspense boundaries")
- Test with React DevTools Profiler
Ask Cursor:
Add production-ready error handling and rate limiting to all Server Actions
Tested on Next.js 16.0.2, Cursor 0.42.3, Node.js 22.x, macOS Sequoia
Cursor Settings Used:
- Model: Claude 3.7 Sonnet
- Composer mode: Agent
- Context: Medium (4000 lines)