Skip to content

styxlab/nextjs-forms-rsa-single-client-server-validation

Repository files navigation

Full-Stack Form Implementation with Server Actions & Zod

Note: This route is intended as a live code example and is only available in a development environment. It will return a 404 error in production.

This document outlines our robust, type-safe, and progressively enhanced form implementation using Next.js Server Actions, Zod for validation, and a custom hook for state management.

Overview

This architecture provides a seamless way to handle form submissions with shared validation logic between the client and server. It ensures a great developer experience (DX) and user experience (UX) by centralizing logic and providing instant feedback.

The core idea is to define a single Zod schema that serves as the single source of truth for validation rules and data types. This schema is used on the client for instant validation and on the server for security and data integrity.

Key Features & Benefits

  • Single Source of Truth: A Zod schema (schema.ts) defines validation rules and data shapes in one place.
  • End-to-End Type Safety: The schema's inferred type is used across the client component, the server action, and the business logic, eliminating a whole class of bugs.
  • Progressive Enhancement: The form works without JavaScript. Submissions are handled by the server action, and validation is performed on the server.
  • Clean, Reusable Logic: The useFormServerAction hook encapsulates all the complex form state management, while createFormAction abstracts away the boilerplate of server-side validation.
  • Excellent UX: Users get immediate, client-side validation feedback before submitting the form.

Third-Party Libraries

This implementation leverages two powerful, industry-standard libraries:

  • zod: For declarative, type-safe validation.
  • react-hook-form & @hookform/resolvers/zod: For robust and performant form state management on the client.

How It Works

  1. schema.ts: Defines the Zod schema and exports the schema object and its inferred TypeScript type.

    export const schema = z.object({ /* ... */ });
    export type InputData = z.infer<typeof schema>;
  2. ContactForm.tsx: A Client Component that uses the useFormServerAction hook to manage state, register inputs, and handle submissions.

    const { register, formState: { errors }, submit } = useFormServerAction(schema, submitForm);
    <form onSubmit={submit}>
      <input {...register('email')} />
      {errors.email && <p>{errors.email.message}</p>}
    </form>
  3. actions.ts: A Server Action file that uses our createFormAction factory. This factory takes the schema and a processing function, and returns a fully-validated server action.

    async function processContactForm(data: InputData) {
      console.log(data.email, data.message)
      return { success: true, message: 'Thank you for your message!' }
    }
    export const submitForm = createFormAction(schema, processContactForm);

Simple Usage Example

The example below shows a simplified version of a form component to illustrate how the useFormServerAction hook is used to wire everything together.

It demonstrates how to register fields, display validation errors, show the server's response message, and handle the submission state.

// Simplified Form Component Example
'use client'

import { useFormServerAction } from '@/app/lib/forms/useFormServerAction'
import { schema } from './schema'      // 1. Import your Zod schema
import { submitForm } from './actions' // 2. Import your server action

export function SimplifiedContactForm() {
  // 3. Call the hook with the schema and action
  const {
    register,
    formState: { errors },
    isPending,
    state, // This holds the response from the server action
    submit,
  } = useFormServerAction(schema, submitForm)

  return (
    // 4. Attach the submit handler
    <form onSubmit={submit} noValidate>
      <div>
        <label htmlFor="email">Email</label>
        {/* 5. Register your input */}
        <input id="email" type="email" {...register('email')} />
        {/* 6. Display validation errors */}
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" {...register('message')} />
        {errors.message && <p>{errors.message.message}</p>}
      </div>

      {/* 7. Display the server response message */}
      {state && <p>{state.message}</p>}

      {/* 8. Use `isPending` to give user feedback */}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  )
}

Implementation Details

Below are the full implementations for the reusable useFormServerAction hook and the createFormAction factory.

useFormServerAction Hook

This custom hook abstracts the client-side form logic. It integrates react-hook-form with our server action, providing client-side validation against the Zod schema before submission.

// app/lib/forms/useFormServerAction.ts
import { useForm, UseFormReturn, Path } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useEffect, useState } from 'react'
import { type ActionResponse } from './formActions'

export const useFormServerAction = <T extends z.ZodRawShape>(
  schema: z.ZodObject<T>,
  action: (formData: FormData) => Promise<ActionResponse>,
) => {
  const [isPending, setIsPending] = useState<boolean>(false)
  const [result, setResult] = useState<ActionResponse | null>(null)

  const form: UseFormReturn<z.infer<z.ZodObject<T>>> = useForm<z.infer<z.ZodObject<T>>>({
    resolver: zodResolver(schema),
    mode: 'onBlur',
  })

  const {
    register: registerForm,
    handleSubmit,
    formState: { errors },
    reset,
    clearErrors,
  } = form

  // Auto-reset form on successful submission
  useEffect(() => {
    if (result?.success) {
      reset()
    }
  }, [result?.success, reset])

  const submit = handleSubmit(async (data: z.infer<z.ZodObject<T>>) => {
    setIsPending(true)
    setResult(null)

    try {
      const formData = new FormData()
      Object.entries(data).forEach(([key, value]) => {
        formData.append(key, String(value))
      })

      const response: ActionResponse = await action(formData)
      setResult(response)
    } catch (error) {
      console.error(error)
      const errorResponse: ActionResponse = {
        success: false,
        message: 'An unexpected error occurred. Please try again.',
      }
      setResult(errorResponse)
    } finally {
      setIsPending(false)
    }
  })

  const register = (name: Path<z.infer<z.ZodObject<T>>>) => ({
    ...registerForm(name),
    onFocus: () => {
      if (errors[name]) {
        clearErrors(name)
      }
    },
    onChange: () => {
      if (errors[name]) {
        clearErrors(name)
      }
    },
  })

  return {
    register,
    formState: { errors },
    reset,
    isPending,
    state: result,
    submit,
  }
}

createFormAction Factory

This server-side factory function takes a Zod schema and a processing function. It returns a standard Next.js Server Action that automatically handles FormData parsing and validation before executing your core business logic.

// app/lib/forms/formActions.ts
import { z } from 'zod'

// Define the response type
export type ActionResponse = {
  success: boolean
  message: string
  errors?: Record<string, string[]>
}

// Generic form action handler
export function createFormAction<T extends z.ZodRawShape>(
  schema: z.ZodObject<T> | z.ZodEffects<z.ZodObject<T>>,
  processor: (data: z.infer<z.ZodObject<T>>) => Promise<ActionResponse>,
) {
  return async (formData: FormData): Promise<ActionResponse> => {
    // Extract form data manually to handle both ZodObject and ZodEffects
    const rawData: Record<string, unknown> = {}

    // Get the shape from the schema (handle both ZodObject and ZodEffects)
    const schemaShape = 'shape' in schema ? schema.shape : schema._def.schema.shape

    Object.keys(schemaShape).forEach((key) => {
      const value = formData.get(key)
      if (value !== null) {
        rawData[key] = value.toString()
      } else {
        rawData[key] = ''
      }
    })

    const result = schema.safeParse(rawData)
    console.log('Form data parsed:', result)

    if (!result.success) {
      const fieldErrors = result.error.flatten().fieldErrors
      // Filter out undefined values to match Record<string, string[]>
      const cleanErrors: Record<string, string[]> = {}
      Object.entries(fieldErrors).forEach(([key, value]) => {
        if (Array.isArray(value) && value.length > 0) {
          cleanErrors[key] = value
        }
      })

      return {
        success: false,
        message: 'Invalid input data. Please check your entries.',
        errors: cleanErrors,
      }
    }

    try {
      const response = await processor(result.data)
      return {
        success: response.success,
        message: response.message,
      }
    } catch (error) {
      console.error('Form processing error:', error)
      return {
        success: false,
        message: 'An error occurred. Please try again later.',
      }
    }
  }
} 

About

Concept for Next.js 15, React 19, React Server Actions implementation with single source of truth validation with Zod

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published