Improving Accessibility
In the previous chapter, we looked at how to catch errors (including 404 errors) and display a fallback to the user. However, we still need to discuss another piece of the puzzle: form validation. Let's see how to implement server-side validation with Server Actions, and how you can show form errors using React's useActionState
hook - while keeping accessibility in mind!
What is accessibility?
Accessibility refers to designing and implementing web applications that everyone can use, including those with disabilities. It's a vast topic that covers many areas, such as keyboard navigation, semantic HTML, images, colors, videos, etc.
While we won't go in-depth into accessibility in this course, we'll discuss the accessibility features available in Next.js and some common practices to make your applications more accessible.
If you'd like to learn more about accessibility, we recommend the Learn Accessibility course by web.dev.
Using the ESLint accessibility plugin in Next.js
Next.js includes the eslint-plugin-jsx-a11y
plugin in its ESLint config to help catch accessibility issues early. For example, this plugin warns if you have images without alt
text, use the aria-*
and role
attributes incorrectly, and more.
Optionally, if you would like to try this out, add next lint
as a script in your package.json
file:
Then run pnpm lint
in your terminal:
This will guide you through installing and configuring ESLint for your project. If you were to run pnpm lint
now, you should see the following output:
However, what would happen if you had an image without alt
text? Let's find out!
Go to /app/ui/invoices/table.tsx
and remove the alt
prop from the image. You can use your editor's search feature to quickly find the <Image>
:
Now run pnpm lint
again, and you should see the following warning:
While adding and configuring a linter is not a required step, it can be helpful to catch accessibility issues in your development process.
Improving form accessibility
There are three things we're already doing to improve accessibility in our forms:
- Semantic HTML: Using semantic elements (
<input>
,<option>
, etc) instead of<div>
. This allows assistive technologies (AT) to focus on the input elements and provide appropriate contextual information to the user, making the form easier to navigate and understand. - Labelling: Including
<label>
and thehtmlFor
attribute ensures that each form field has a descriptive text label. This improves AT support by providing context and also enhances usability by allowing users to click on the label to focus on the corresponding input field. - Focus Outline: The fields are properly styled to show an outline when they are in focus. This is critical for accessibility as it visually indicates the active element on the page, helping both keyboard and screen reader users to understand where they are on the form. You can verify this by pressing
tab
.
These practices lay a good foundation for making your forms more accessible to many users. However, they don't address form validation and errors.
Form validation
Go to http://localhost:3000/dashboard/invoices/create, and submit an empty form. What happens?
You get an error! This is because you're sending empty form values to your Server Action. You can prevent this by validating your form on the client or the server.
Client-Side validation
There are a couple of ways you can validate forms on the client. The simplest would be to rely on the form validation provided by the browser by adding the required
attribute to the <input>
and <select>
elements in your forms. For example:
Submit the form again. The browser will display a warning if you try to submit a form with empty values.
This approach is generally okay because some ATs support browser validation.
An alternative to client-side validation is server-side validation. Let's see how you can implement it in the next section. For now, delete the required
attributes if you added them.
Server-Side validation
By validating forms on the server, you can:
- Ensure your data is in the expected format before sending it to your database.
- Reduce the risk of malicious users bypassing client-side validation.
- Have one source of truth for what is considered valid data.
In your create-form.tsx
component, import the useActionState
hook from react
. Since useActionState
is a hook, you will need to turn your form into a Client Component using "use client"
directive:
Inside your Form Component, the useActionState
hook:
- Takes two arguments:
(action, initialState)
. - Returns two values:
[state, formAction]
- the form state, and a function to be called when the form is submitted.
Pass your createInvoice
action as an argument of useActionState
, and inside your <form action={}>
attribute, call formAction
.
The initialState
can be anything you define, in this case, create an object with two empty keys: message
and errors
, and import the State
type from your actions.ts
file. State
does not yet exist, but we will create it next:
This may seem confusing initially, but it'll make more sense once you update the server action. Let's do that now.
In your action.ts
file, you can use Zod to validate form data. Update your FormSchema
as follows:
customerId
- Zod already throws an error if the customer field is empty as it expects a typestring
. But let's add a friendly message if the user doesn't select a customer.amount
- Since you are coercing the amount type fromstring
tonumber
, it'll default to zero if the string is empty. Let's tell Zod we always want the amount greater than 0 with the.gt()
function.status
- Zod already throws an error if the status field is empty as it expects "pending" or "paid". Let's also add a friendly message if the user doesn't select a status.
Next, update your createInvoice
action to accept two parameters - prevState
and formData
:
formData
- same as before.prevState
- contains the state passed from theuseActionState
hook. You won't be using it in the action in this example, but it's a required prop.
Then, change the Zod parse()
function to safeParse()
:
safeParse()
will return an object containing either a success
or error
field. This will help handle validation more gracefully without having put this logic inside the try/catch
block.
Before sending the information to your database, check if the form fields were validated correctly with a conditional:
If validatedFields
isn't successful, we return the function early with the error messages from Zod.
Tip: console.log
validatedFields
and submit an empty form to see the shape of it.
Finally, since you're handling form validation separately, outside your try/catch block, you can return a specific message for any database errors, your final code should look like this:
Great, now let's display the errors in your form component. Back in the create-form.tsx
component, you can access the errors using the form state
.
Add a ternary operator that checks for each specific error. For example, after the customer's field, you can add:
Tip: You can console.log
state
inside your component and check if everything is wired correctly. Check the console in Dev Tools as your form is now a Client Component.
In the code above, you're also adding the following aria labels:
aria-describedby="customer-error"
: This establishes a relationship between theselect
element and the error message container. It indicates that the container withid="customer-error"
describes theselect
element. Screen readers will read this description when the user interacts with theselect
box to notify them of errors.id="customer-error"
: Thisid
attribute uniquely identifies the HTML element that holds the error message for theselect
input. This is necessary foraria-describedby
to establish the relationship.aria-live="polite"
: The screen reader should politely notify the user when the error inside thediv
is updated. When the content changes (e.g. when a user corrects an error), the screen reader will announce these changes, but only when the user is idle so as not to interrupt them.
Practice: Adding aria labels
Using the example above, add errors to your remaining form fields. You should also show a message at the bottom of the form if any fields are missing. Your UI should look like this:

Once you're ready, run pnpm lint
to check if you're using the aria labels correctly.
If you'd like to challenge yourself, take the knowledge you've learned in this chapter and add form validation to the edit-form.tsx
component.
You'll need to:
- Add
useActionState
to youredit-form.tsx
component. - Edit the
updateInvoice
action to handle validation errors from Zod. - Display the errors in your component, and add aria labels to improve accessibility.
Once you're ready, expand the code snippet below to see the solution: