WDD 430: Web Full-Stack Development

Improving Accessibility

Overview

In the previous chapter, you handled errors with try...catch and route-level error boundaries. In this lesson, you will focus on accessibility and form validation for your portfolio /projects workflow. You will configure Next.js linting for accessibility checks, improve form semantics, and implement server-side validation with useActionState and Zod.

By the end of this activity, you should be able to:

Prepare

Accessibility

Accessibility means building experiences that everyone can use, including people who navigate with keyboards, screen readers, or assistive technologies. In this lesson, the focus is specifically on form accessibility and validation, not the full accessibility landscape.

Using the ESLint Accessibility Plugin in Next.js

Next.js ships with eslint-config-next, which bundles eslint-plugin-jsx-a11y. You do not install the plugin separately. You still need ESLint setup so those rules can run.

1. Install ESLint
npm install -D eslint eslint-config-next
2. Create eslint.config.mjs in the project root
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';

const eslintConfig = defineConfig([
  ...nextVitals,
  globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
]);

export default eslintConfig;
3. Add a lint script to package.json
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "eslint ."
}
4. Run lint
npm run lint

To test the rule set, temporarily remove an alt attribute from an image and rerun lint. You should see a jsx-a11y/alt-text warning.

Improving Form Accessibility

Before validating data, make your form structurally accessible:

Validation Strategy

Client-Side Validation

Client-side validation provides immediate feedback. It is helpful for usability, but it is not your source of truth.

<input id="title" name="title" required />
Server-Side Validation (Source of Truth)

Server-side validation protects data integrity and cannot be bypassed by disabling browser validation. In Next.js, pair Server Actions with Zod validation and useActionState so field errors are returned to the form.

Complete Example: useActionState + Zod (Projects App)

// app/projects/create/create-project-form.tsx
'use client';

import { useActionState } from 'react';
import { createProject, type State } from '@/app/lib/actions';

const initialState: State = { message: null, errors: {} };

export default function CreateProjectForm() {
  const [state, formAction, isPending] = useActionState(createProject, initialState);

  return (
    <form action={formAction} className="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
      <div>
        <label htmlFor="title" className="mb-2 block text-sm font-medium text-slate-700">
          Project Title
        </label>
        <input
          id="title"
          name="title"
          type="text"
          className="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none ring-blue-500 focus:ring-2"
          aria-describedby="title-error"
          required
        />
        <div id="title-error" aria-live="polite" aria-atomic="true">
          {state.errors?.title?.map((error) => (
            <p key={error} className="mt-1 text-sm text-red-600">
              {error}
            </p>
          ))}
        </div>
      </div>

      <div>
        <label htmlFor="description" className="mb-2 block text-sm font-medium text-slate-700">
          Description
        </label>
        <textarea
          id="description"
          name="description"
          rows={4}
          className="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none ring-blue-500 focus:ring-2"
          aria-describedby="description-error"
          required
        />
        <div id="description-error" aria-live="polite" aria-atomic="true">
          {state.errors?.description?.map((error) => (
            <p key={error} className="mt-1 text-sm text-red-600">
              {error}
            </p>
          ))}
        </div>
      </div>

      <div>
        <label htmlFor="technologies" className="mb-2 block text-sm font-medium text-slate-700">
          Technologies (comma-separated)
        </label>
        <input
          id="technologies"
          name="technologies"
          type="text"
          className="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none ring-blue-500 focus:ring-2"
          aria-describedby="technologies-error"
          required
        />
        <div id="technologies-error" aria-live="polite" aria-atomic="true">
          {state.errors?.technologies?.map((error) => (
            <p key={error} className="mt-1 text-sm text-red-600">
              {error}
            </p>
          ))}
        </div>
      </div>

      <div>
        <label htmlFor="yearCompleted" className="mb-2 block text-sm font-medium text-slate-700">
          Year Completed
        </label>
        <input
          id="yearCompleted"
          name="yearCompleted"
          type="number"
          min="2000"
          max="2099"
          className="block w-full rounded-md border border-slate-300 px-3 py-2 text-sm outline-none ring-blue-500 focus:ring-2"
          aria-describedby="yearCompleted-error"
          required
        />
        <div id="yearCompleted-error" aria-live="polite" aria-atomic="true">
          {state.errors?.yearCompleted?.map((error) => (
            <p key={error} className="mt-1 text-sm text-red-600">
              {error}
            </p>
          ))}
        </div>
      </div>

      {state.message ? <p className="text-sm text-red-600">{state.message}</p> : null}

      <button
        type="submit"
        disabled={isPending}
        className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
      >
        {isPending ? 'Saving...' : 'Save Project'}
      </button>
    </form>
  );
}

initialState must match the state shape returned by your server action.

// app/lib/actions.ts
'use server';

import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

const currentYear = new Date().getFullYear();

const CreateProjectSchema = z.object({
  title: z.string().min(3, 'Title must be at least 3 characters.'),
  description: z.string().min(20, 'Description must be at least 20 characters.'),
  technologies: z.string().min(2, 'Add at least one technology.'),
  yearCompleted: z.coerce
    .number()
    .int('Year must be a whole number.')
    .gte(2000, 'Year must be 2000 or later.')
    .lte(currentYear, `Year cannot be greater than ${currentYear}.`),
});

export type State = {
  errors?: {
    title?: string[];
    description?: string[];
    technologies?: string[];
    yearCompleted?: string[];
  };
  message?: string | null;
};

export async function createProject(prevState: State, formData: FormData): Promise<State | void> {
  const validatedFields = CreateProjectSchema.safeParse({
    title: formData.get('title'),
    description: formData.get('description'),
    technologies: formData.get('technologies'),
    yearCompleted: formData.get('yearCompleted'),
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing or invalid fields. Failed to create project.',
    };
  }

  const { title, description, technologies, yearCompleted } = validatedFields.data;

  try {
    await sql`
      INSERT INTO projects (title, description, technologies, year_completed)
      VALUES (${title}, ${description}, ${technologies}, ${yearCompleted})
    `;
  } catch (error) {
    return {
      message: 'Database Error: Failed to create project.',
    };
  }

  revalidatePath('/projects');
  redirect('/projects');
}

Activity Instructions

  1. Add yearCompleted to your create project form as a numeric field and label it clearly.
  2. Configure useActionState with an initialState object containing message and errors.
  3. Use Zod safeParse in your createProject Server Action and return field-level errors.
  4. Display field errors with aria-describedby and aria-live="polite".
  5. Revalidate and redirect on success, then test with both valid and invalid form input.