Forms & Server Actions
This guide covers how to build robust, type-safe forms using Next.js Server Actions in the Visita Intelligence Platform. Server Actions provide a secure, efficient way to handle form submissions with built-in validation and error handling.Overview
Visita uses Server Actions for all form handling because they:- Run on the server - Secure, no client-side exposure of sensitive logic
- Support progressive enhancement - Work without JavaScript
- Integrate with Next.js caching - Optimize data fetching and revalidation
- Provide type safety - Full TypeScript support throughout
Quick Start
Basic Form with Server Action
app/actions/contact.ts
Copy
'use server';
import { createClient } from '@/lib/supabase/server';
import { z } from 'zod';
// Define validation schema
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
wardCode: z.string().optional(),
});
export async function submitContactForm(prevState: any, formData: FormData) {
const supabase = await createClient();
try {
// Validate form data
const validatedFields = contactSchema.parse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
wardCode: formData.get('wardCode'),
});
// Verify user authentication (if needed)
const { data: { user } } = await supabase.auth.getUser();
// Insert into database
const { error } = await supabase
.from('contact_submissions')
.insert({
name: validatedFields.name,
email: validatedFields.email,
message: validatedFields.message,
ward_code: validatedFields.wardCode,
user_id: user?.id,
});
if (error) {
throw error;
}
return {
success: true,
message: 'Thank you for your message! We will respond within 24 hours.',
};
} catch (error) {
console.error('Contact form error:', error);
return {
success: false,
message: 'Failed to submit form. Please try again.',
};
}
}
components/forms/ContactForm.tsx
Copy
'use client';
import { useActionState } from 'react';
import { submitContactForm } from '@/app/actions/contact';
export function ContactForm() {
const [state, formAction, isPending] = useActionState(
submitContactForm,
{ message: '', success: false }
);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Full Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email Address
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
rows={4}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{state.message && (
<div
className={`p-3 rounded-md ${
state.success
? 'bg-green-50 text-green-800'
: 'bg-red-50 text-red-800'
}`}
>
{state.message}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Submitting...' : 'Send Message'}
</button>
</form>
);
}
Form Validation
Using Zod for Type Safety
lib/validation/schemas.ts
Copy
import { z } from 'zod';
// Ward signal submission schema
export const signalSchema = z.object({
title: z.string().min(5, 'Title must be at least 5 characters'),
description: z.string().min(20, 'Description must be at least 20 characters'),
category: z.enum(['safety', 'infrastructure', 'environment', 'other']),
location: z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
}),
urgency: z.enum(['low', 'medium', 'high']),
wardCode: z.string(),
});
// Community project proposal schema
export const projectSchema = z.object({
title: z.string().min(10).max(200),
description: z.string().min(50),
budget: z.number().min(0, 'Budget cannot be negative'),
timeline: z.enum(['1-3 months', '3-6 months', '6-12 months', '1+ years']),
wardCode: z.string(),
});
export type SignalFormData = z.infer<typeof signalSchema>;
export type ProjectFormData = z.infer<typeof projectSchema>;
Server-Side Validation
app/actions/signals.ts
Copy
'use server';
import { signalSchema } from '@/lib/validation/schemas';
import { createClient } from '@/lib/supabase/server';
export async function submitSignal(prevState: any, formData: FormData) {
const supabase = await createClient();
try {
// Parse and validate
const validated = signalSchema.parse({
title: formData.get('title'),
description: formData.get('description'),
category: formData.get('category'),
location: {
lat: parseFloat(formData.get('lat') as string),
lng: parseFloat(formData.get('lng') as string),
},
urgency: formData.get('urgency'),
wardCode: formData.get('wardCode'),
});
// Verify user is authenticated
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return {
success: false,
message: 'You must be logged in to submit a signal',
};
}
// Verify user has permission for this ward
const { data: userWard } = await supabase
.from('user_wards')
.select('ward_code')
.eq('user_id', user.id)
.eq('ward_code', validated.wardCode)
.single();
if (!userWard) {
return {
success: false,
message: 'You do not have permission to submit signals for this ward',
};
}
// Create signal
const { error } = await supabase
.from('signals')
.insert({
title: validated.title,
description: validated.description,
category: validated.category,
location: validated.location,
urgency: validated.urgency,
ward_code: validated.wardCode,
creator_id: user.id,
status: 'pending',
});
if (error) throw error;
return {
success: true,
message: 'Signal submitted successfully! It will be reviewed within 24 hours.',
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
message: error.errors[0].message,
};
}
console.error('Signal submission error:', error);
return {
success: false,
message: 'Failed to submit signal. Please try again.',
};
}
}
File Uploads
Handling File Uploads with Server Actions
app/actions/upload.ts
Copy
'use server';
import { createClient } from '@/lib/supabase/server';
import { z } from 'zod';
const uploadSchema = z.object({
file: z.instanceof(File),
wardCode: z.string(),
description: z.string().optional(),
});
export async function uploadDocument(prevState: any, formData: FormData) {
const supabase = await createClient();
try {
const file = formData.get('file') as File;
const wardCode = formData.get('wardCode') as string;
const description = formData.get('description') as string;
// Validate file
if (!file || file.size === 0) {
return { success: false, message: 'Please select a file' };
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
return { success: false, message: 'File size must be less than 5MB' };
}
// Verify user authentication
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, message: 'Authentication required' };
}
// Upload to Supabase Storage
const fileName = `${Date.now()}-${file.name}`;
const { error: uploadError } = await supabase.storage
.from('ward-documents')
.upload(`${wardCode}/${fileName}`, file);
if (uploadError) throw uploadError;
// Record in database
const { error: dbError } = await supabase
.from('ward_documents')
.insert({
file_name: fileName,
original_name: file.name,
file_size: file.size,
mime_type: file.type,
ward_code: wardCode,
description,
uploaded_by: user.id,
});
if (dbError) throw dbError;
return {
success: true,
message: 'Document uploaded successfully',
};
} catch (error) {
console.error('Upload error:', error);
return {
success: false,
message: 'Failed to upload document',
};
}
}
components/forms/DocumentUpload.tsx
Copy
'use client';
import { useActionState } from 'react';
import { uploadDocument } from '@/app/actions/upload';
export function DocumentUpload({ wardCode }: { wardCode: string }) {
const [state, formAction, isPending] = useActionState(
uploadDocument,
{ message: '', success: false }
);
return (
<form action={formAction} className="space-y-4">
<input type="hidden" name="wardCode" value={wardCode} />
<div>
<label htmlFor="file" className="block text-sm font-medium mb-1">
Document
</label>
<input
type="file"
id="file"
name="file"
accept=".pdf,.doc,.docx,.txt"
required
className="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100"
/>
<p className="text-xs text-gray-500 mt-1">
Accepted formats: PDF, DOC, DOCX, TXT (max 5MB)
</p>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Description (optional)
</label>
<textarea
id="description"
name="description"
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{state.message && (
<div
className={`p-3 rounded-md ${
state.success
? 'bg-green-50 text-green-800'
: 'bg-red-50 text-red-800'
}`}
>
{state.message}
</div>
)}
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Uploading...' : 'Upload Document'}
</button>
</form>
);
}
Multi-Step Forms
Wizard-Style Forms with State Management
components/forms/ProjectProposalWizard.tsx
Copy
'use client';
import { useState } from 'react';
import { useActionState } from 'react';
import { submitProjectProposal } from '@/app/actions/projects';
type FormData = {
title: string;
description: string;
budget: string;
timeline: string;
wardCode: string;
contactName: string;
contactEmail: string;
};
export function ProjectProposalWizard({ wardCode }: { wardCode: string }) {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState<FormData>({
title: '',
description: '',
budget: '',
timeline: '',
wardCode,
contactName: '',
contactEmail: '',
});
const [state, formAction, isPending] = useActionState(
submitProjectProposal,
{ message: '', success: false }
);
const steps = [
{ number: 1, title: 'Project Details' },
{ number: 2, title: 'Budget & Timeline' },
{ number: 3, title: 'Contact Information' },
];
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1);
}
};
const handlePrevious = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="max-w-2xl mx-auto">
{/* Progress Steps */}
<nav aria-label="Progress" className="mb-8">
<ol className="flex items-center">
{steps.map((step, index) => (
<li key={step.number} className={index !== steps.length - 1 ? 'flex-1' : ''}>
<div className="flex items-center">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full border-2 ${
currentStep >= step.number
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-300 text-gray-500'
}`}
>
{step.number}
</div>
{index !== steps.length - 1 && (
<div
className={`flex-1 h-0.5 mx-4 ${
currentStep > step.number ? 'bg-blue-600' : 'bg-gray-300'
}`}
/>
)}
</div>
<span className="text-xs mt-2 block text-center">{step.title}</span>
</li>
))}
</ol>
</nav>
<form action={formAction}>
{/* Hidden inputs for all form data */}
{Object.entries(formData).map(([key, value]) => (
<input key={key} type="hidden" name={key} value={value} />
))}
{/* Step 1: Project Details */}
{currentStep === 1 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold mb-4">Project Details</h3>
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Project Title
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
Description
</label>
<textarea
id="description"
name="description"
rows={6}
value={formData.description}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="button"
onClick={handleNext}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
>
Next
</button>
</div>
)}
{/* Step 2: Budget & Timeline */}
{currentStep === 2 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold mb-4">Budget & Timeline</h3>
<div>
<label htmlFor="budget" className="block text-sm font-medium mb-1">
Estimated Budget (ZAR)
</label>
<input
type="number"
id="budget"
name="budget"
value={formData.budget}
onChange={handleInputChange}
min="0"
step="1000"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="timeline" className="block text-sm font-medium mb-1">
Estimated Timeline
</label>
<select
id="timeline"
name="timeline"
value={formData.timeline}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="">Select timeline</option>
<option value="1-3 months">1-3 months</option>
<option value="3-6 months">3-6 months</option>
<option value="6-12 months">6-12 months</option>
<option value="1+ years">1+ years</option>
</select>
</div>
<div className="flex gap-4">
<button
type="button"
onClick={handlePrevious}
className="bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300"
>
Previous
</button>
<button
type="button"
onClick={handleNext}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
>
Next
</button>
</div>
</div>
)}
{/* Step 3: Contact Information */}
{currentStep === 3 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold mb-4">Contact Information</h3>
<div>
<label htmlFor="contactName" className="block text-sm font-medium mb-1">
Contact Person
</label>
<input
type="text"
id="contactName"
name="contactName"
value={formData.contactName}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="contactEmail" className="block text-sm font-medium mb-1">
Email Address
</label>
<input
type="email"
id="contactEmail"
name="contactEmail"
value={formData.contactEmail}
onChange={handleInputChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{state.message && (
<div
className={`p-3 rounded-md ${
state.success
? 'bg-green-50 text-green-800'
: 'bg-red-50 text-red-800'
}`}
>
{state.message}
</div>
)}
<div className="flex gap-4">
<button
type="button"
onClick={handlePrevious}
className="bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300"
>
Previous
</button>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Submitting...' : 'Submit Proposal'}
</button>
</div>
</div>
)}
</form>
</div>
);
}
Best Practices
1. Always Validate on Server
Copy
// ❌ Don't rely only on client-side validation
'use client';
function BadForm() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Client-side only validation - insecure!
};
}
// ✅ Always validate on server
'use server';
export async function goodAction(formData: FormData) {
// Server-side validation - secure!
const validated = schema.parse(Object.fromEntries(formData));
}
2. Use Type-Safe Form Data
Copy
// ❌ Don't use 'as any'
const email = formData.get('email') as string; // Unsafe
// ✅ Use Zod for type safety
const { email } = schema.parse({
email: formData.get('email'),
});
3. Handle Errors Gracefully
Copy
export async function submitForm(prevState: any, formData: FormData) {
try {
// ... validation and processing
return {
success: true,
message: 'Success!',
};
} catch (error) {
console.error('Form error:', error);
// Return user-friendly error
return {
success: false,
message: 'Something went wrong. Please try again.',
};
}
}
4. Implement Rate Limiting
app/actions/rate-limited.ts
Copy
import { rateLimit } from '@/lib/rate-limit';
// Limit to 5 submissions per minute per user
const submitRateLimit = rateLimit({
interval: 60 * 1000, // 1 minute
allowedPerInterval: 5,
});
export async function submitRateLimitedForm(prevState: any, formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, message: 'Authentication required' };
}
// Check rate limit
const { success } = await submitRateLimit.limit(user.id);
if (!success) {
return {
success: false,
message: 'Too many submissions. Please wait a moment.',
};
}
// ... rest of form processing
}
Testing Forms
Unit Testing Server Actions
__tests__/actions/contact.test.ts
Copy
import { submitContactForm } from '@/app/actions/contact';
import { createClient } from '@/lib/supabase/server';
jest.mock('@/lib/supabase/server');
describe('submitContactForm', () => {
it('validates required fields', async () => {
const formData = new FormData();
formData.append('name', '');
formData.append('email', 'invalid-email');
formData.append('message', 'short');
const result = await submitContactForm(null, formData);
expect(result.success).toBe(false);
expect(result.message).toContain('Invalid');
});
it('submits valid form', async () => {
const formData = new FormData();
formData.append('name', 'John Doe');
formData.append('email', '[email protected]');
formData.append('message', 'This is a detailed message');
formData.append('wardCode', 'ward-123');
const result = await submitContactForm(null, formData);
expect(result.success).toBe(true);
expect(result.message).toContain('Thank you');
});
});
E2E Testing with Playwright
e2e/contact-form.spec.ts
Copy
import { test, expect } from '@playwright/test';
test.describe('Contact Form', () => {
test('submits successfully', async ({ page }) => {
await page.goto('/contact');
await page.fill('[name="name"]', 'Test User');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="message"]', 'This is a test message');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-green-50')).toBeVisible();
await expect(page.locator('.bg-green-50')).toContainText('Thank you');
});
test('shows validation errors', async ({ page }) => {
await page.goto('/contact');
await page.fill('[name="name"]', '');
await page.fill('[name="email"]', 'invalid-email');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-red-50')).toBeVisible();
});
});
Troubleshooting
Common Issues
Issue: “Form submission failed”- Check server action is marked with
'use server' - Verify database permissions and RLS policies
- Check error logs in Supabase dashboard
- Ensure Zod schema matches form field names
- Check that
formData.get()uses correct field names - Verify Zod schema is properly imported
- Check Supabase Storage bucket exists
- Verify RLS policies on storage bucket
- Check file size limits
- Ensure proper MIME type validation
- Adjust rate limit configuration
- Consider different limits for authenticated vs anonymous users
- Implement exponential backoff
Related Guides
- Authentication System - User authentication patterns
- Database Schema - Data modeling for forms
- Testing - Comprehensive testing strategies
- Server Actions - API reference for Server Actions