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:
- Run accessibility lint rules in a Next.js app.
- Apply semantic form markup, labels, and accessible error messaging.
- Validate project form data on the server with Zod.
- Return field-level validation errors with
useActionState.
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:
- Use semantic form elements:
<form>,<label>,<input>,<textarea>, and<button>. - Associate labels with inputs using
htmlForand matchingid. - Use
aria-describedbyto link fields to error containers. - Use
aria-live="polite"so screen readers announce validation updates. - Keep visible focus styles so keyboard users can track location.
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
- Add
yearCompletedto your create project form as a numeric field and label it clearly. - Configure
useActionStatewith aninitialStateobject containingmessageanderrors. - Use Zod
safeParsein yourcreateProjectServer Action and return field-level errors. - Display field errors with
aria-describedbyandaria-live="polite". - Revalidate and redirect on success, then test with both valid and invalid form input.