Skip to main content

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
'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
'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
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
'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
'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
'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
'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

// ❌ 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

// ❌ 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

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
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
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
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
Issue: “Validation not working”
  • Ensure Zod schema matches form field names
  • Check that formData.get() uses correct field names
  • Verify Zod schema is properly imported
Issue: “File upload fails”
  • Check Supabase Storage bucket exists
  • Verify RLS policies on storage bucket
  • Check file size limits
  • Ensure proper MIME type validation
Issue: “Rate limiting too aggressive”
  • Adjust rate limit configuration
  • Consider different limits for authenticated vs anonymous users
  • Implement exponential backoff