typescriptadvanced

Form with Server Action Validation

Build forms using Next.js server actions with server-side validation, error handling, and useActionState.

typescript
// app/contact/actions.ts
'use server';

interface FormState {
  success: boolean;
  errors: Record<string, string>;
  message: string;
}

function validate(data: { name: string; email: string; message: string }) {
  const errors: Record<string, string> = {};

  if (!data.name || data.name.length < 2) {
    errors.name = 'Name must be at least 2 characters';
  }
  if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
    errors.email = 'Valid email is required';
  }
  if (!data.message || data.message.length < 10) {
    errors.message = 'Message must be at least 10 characters';
  }

  return errors;
}

export async function submitContact(
  prevState: FormState,
  formData: FormData,
): Promise<FormState> {
  const data = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    message: formData.get('message') as string,
  };

  const errors = validate(data);
  if (Object.keys(errors).length > 0) {
    return { success: false, errors, message: 'Please fix the errors below' };
  }

  // Simulate saving
  await new Promise((r) => setTimeout(r, 1000));

  return { success: true, errors: {}, message: 'Message sent successfully!' };
}

// app/contact/page.tsx
'use client';

import { useActionState } from 'react';
import { submitContact } from './actions';

const initialState = { success: false, errors: {} as Record<string, string>, message: '' };

export default function ContactForm() {
  const [state, action, isPending] = useActionState(submitContact, initialState);

  return (
    <form action={action} className="max-w-md mx-auto space-y-4">
      <h1 className="text-2xl font-bold text-white">Contact Us</h1>

      {state.message && (
        <div className={`p-3 rounded-lg text-sm ${
          state.success ? 'bg-green-900/50 text-green-300' : 'bg-red-900/50 text-red-300'
        }`}>
          {state.message}
        </div>
      )}

      <Field name="name" label="Name" error={state.errors.name} />
      <Field name="email" label="Email" type="email" error={state.errors.email} />

      <div>
        <label className="block text-sm text-gray-400 mb-1">Message</label>
        <textarea
          name="message"
          rows={4}
          className="w-full bg-[#111] border border-white/10 rounded-lg px-3 py-2
            text-white focus:outline-none focus:border-blue-500"
        />
        {state.errors.message && (
          <p className="text-red-400 text-sm mt-1">{state.errors.message}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="w-full py-3 bg-blue-600 text-white rounded-lg font-medium
          hover:bg-blue-500 disabled:opacity-50 transition-colors"
      >
        {isPending ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  );
}

function Field({ name, label, type = 'text', error }: {
  name: string; label: string; type?: string; error?: string;
}) {
  return (
    <div>
      <label className="block text-sm text-gray-400 mb-1">{label}</label>
      <input
        name={name}
        type={type}
        className={`w-full bg-[#111] border rounded-lg px-3 py-2 text-white
          focus:outline-none focus:border-blue-500
          ${error ? 'border-red-500' : 'border-white/10'}`}
      />
      {error && <p className="text-red-400 text-sm mt-1">{error}</p>}
    </div>
  );
}

Sponsored

Vercel

Use Cases

  • Contact form submissions
  • Server-validated forms
  • Progressive enhancement forms

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.