How to test for accessibility with axe-core in Next.js and React
To make your app the best it can be for as many people as possible it needs to be accessible. Automatically checking your app for accessibility issues during development enables fast iteration and helps you to easily fix low-hanging issues.
What is axe-core
axe-core
is the world's leading lightweight accessibility testing engine which can be used to automatically test the output of an app against multiple WCAG rules to help pinpoint and fix accessibility issues.
@axe-core/react
(formerly react-axe
) brings the power of axe-core
to React.
If Axe finds any accessibility issues in your app, it logs them to the DevTools console in your browser.
Using Axe can help you find on average 57% of WCAG issues automagically! Neat!
If Axe sounds familiar, it may be because of other tools such as Google's Lighthouse and Microsoft's Accessibility Insights For Web which also use this same technology under the hood. So rest at ease that you're in good company when using axe-core 😌
Implementations of @axe-core/react
The implementation of @axe-core/react
will differ slightly between a typical client-side rendered (CSR) React app and Next.js, so let's cover both approaches. To keep things simple, this example will be using create-react-app and create-next-app as the starting point for each approach.
How to install @axe-core/react
Add the library as a development dependency to begin:
yarn add @axe-core/react -D
# or
npm i @axe-core/react -D
# or
pnpm i @axe-core/react -D
Adding @axe-core/react
to a React project
Keep in mind that the approach for other React projects not based on CRA will be fairly similar, you may just need to configure your tooling appropriately.
It's a good idea to keep the project's main entry point clean and readable. To do that, add the code that will initialize the @axe-core/react
module to a separate utility function.
import type React from 'react'
export const reportAccessibility = async (
App: typeof React,
config?: Record<string, unknown>
): Promise<void> => {
if (process.env.NODE_ENV !== 'production') {
const axe = await import('@axe-core/react')
const ReactDOM = await import('react-dom')
axe.default(App, ReactDOM, 1000, config)
}
}
export default reportAccessibility
Start off with a type-only import of React import type React from "react"
since it's only going to be used for type annotations in this context. Type-only imports always get fully erased so there's no impact on runtime.
The reportAccessibility
function takes two arguments: App
which requires a React instance from your app's entry point and an optional config
which will allow you to do advanced configuration of accessibility rules. In a lot of cases, you will be able to get by fine without passing in a config.
Axe is only going to be used during development, so it's important to ensure that you're not adding unnecessary code to your production bundle! Dynamic imports combined with an environment check guarantee that all the modules are only loaded as needed.
Now head on over to the app's entry point, import the function and call it.
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import reportAccessibility from './reportAccessibility'
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)
reportAccessibility(React)
And that's it!
Now start the dev server, open the app and have a look at the DevTools console in your browser for any reported accessibility violations.
Check out the GitHub repo to see the full implementation, or feel free to skip on to the "Fixing the accessibility issues" section to see how to resolve these accessibility issues.
Adding @axe-core/react
to a Next.js project
Again, let's keep the project's main entry point clean and create a separate utility function that will initialize the module.
import type React from 'react'
export const reportAccessibility = async (
App: typeof React,
config?: Record<string, unknown>
): Promise<void> => {
if (
typeof window !== 'undefined' &&
process.env.NODE_ENV !== 'production'
) {
const axe = await import('@axe-core/react')
const ReactDOM = await import('react-dom')
axe.default(App, ReactDOM, 1000, config)
}
}
export default reportAccessibility
The only difference in this utility is the typeof window !== 'undefined'
check which is required to only run the function when the app is rendered client-side. As with the CRA implementation, you should add an environment check along with dynamic imports to avoid bundling @axe-core/react
in your production bundle.
Ensure that you're adding this to pages/_app
to enable axe to check the entire app for accessibility violations.
import React from 'react'
import reportAccessibility from '../utils/reportAccessibility'
import type { AppProps } from 'next/app'
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />
}
reportAccessibility(React)
export default MyApp
That should do it!
Start the dev server, open the app and investigate the output in your browser's DevTools console.
Have a look at the GitHub repo to see the full implementation, or read on about a simple example of how to resolve this accessibility issue we're getting in the console.
Interpreting the logged accessibility issues
- Severity: The severity of issues range from minor to critical and indicates how much impact it has on users.
- Description: A description of the issue and a link to further information on how to fix it.
- Reference: If relevant, a reference to the element with the issue can be expanded by clicking the toggle.
Fixing the accessibility issues
The approach to fixing accessibility issues will vary depending on the complexity of your app. Keep in mind that these examples are simple, so they can't act as silver bullets on how to approach making all apps accessible.
Do the right thing by researching how to best fix each issue you encounter. Follow the links next to the issue descriptions and read the WCAG 🙏
Fixing the CRA issues
moderate: Document should have one main
landmark https://dequeuniversity.com/rules/axe/4.4/landmark-one-main
moderate: Page should contain a level-one heading https://dequeuniversity.com/rules/axe/4.4/page-has-heading-one
Landmarks are semantic sectioning regions on a page like <main>
, <nav>
, <footer>
and more. These regions improve the navigation experience of your app by making the purpose of the content within them clear and they enable assistive tech users to jump directly from region to region.
All content should be contained within a landmark region. The <main>
element should contain the core content that is unique to the page, not including content that is repeated across pages like navigation, sidebars and footers.
Each page should contain a single <h1>
heading at the start of the main content. This helps screen reader users to create a mental model of the content hierarchy of the page, and easily navigate the page by jumping from header to header.
import logo from './logo.svg'
import './App.css'
function App() {
return (
<div className="App">
<header role="banner" className="App-header">
<img src={logo} className="App-logo" alt="React logo" />
</header>
<main role="main">
<h1>
Edit <code>src/App.tsx</code> and save to reload.
</h1>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</main>
</div>
)
}
export default App
You can fix both issues by simply refactoring the App
component to wrap the logo in a <header>
, the main content in a <main>
and the title in a <h1>
.
Adding ARIA landmark roles with role
attributes is a best practice to ensure that the implementation is as robust as possible across all assistive tech.
Fixing the Next.js issue
serious: <html>
element must have a lang
attribute https://dequeuniversity.com/rules/axe/4.4/html-has-lang
When an app doesn't have a lang
attribute, it can cause screen readers to mispronounce text, making the entire app impossible to understand in worst-case scenarios! In addition, assistive tech like screen readers will assume that the default language is set by the user, which can cause issues if the person speaks multiple languages or access an app with more than one language.
This issue is serious, and luckily also quite easy to fix!
The simplest way to add a lang
attribute to a Next.js app with only one language is to use Next's Internationalized Routing by modifying next.config.js
, adding in values for locales
and defaultLocale
.
module.exports = {
reactStrictMode: true,
i18n: {
/**
* All the locales you want to
* support in your application
*/
locales: ['en'],
/**
* The default locale you want to use when
* on a non-locale prefixed path e.g. `/hello`
*/
defaultLocale: 'en',
},
}
Be as specific as possible with your locale identifier and add a region suffix if possible, e.g en-US
or en-AU
.
Another approach is adding a custom document in a pages/_document.tsx
file and modifying the html
element directly.
Wrapping up
Setting up @axe-core/react
to run accessibility checks in your React or Next.js app during development is straightforward, and a good first step for developers wanting to make the web a better place for everyone.
Making a practice of following this up by checking the DevTools console for any logged issues, clicking the link to learn more, and then implementing the suggested changes will level up your skills, and help you create apps that users return to again and again.