Skip to main content
BlogWeb Dev

How to Build an Accessible Website in Next.js (Beginners)

Cara membuat website ramah aksesibilitas Next.js for beginners: semantic HTML, labels, skip link, focus ring, contrast, reduced motion and quick checks.

Matthews Wong

Software Engineer

October 202610 min read
How to Build an Accessible Website in Next.js (Beginners)

Key Takeaway

To build an accessible website in Next.js: use semantic HTML such as main, button and label, keep keyboard focus clearly visible, add a skip link, measure text contrast at 4.5:1 or better, and honour prefers-reduced-motion. Then test with the Tab key, ESLint, Lighthouse and axe-core.

You press Tab on the page you just built and focus jumps somewhere unexpected. Or the card button you made from a div works with a mouse but a keyboard cannot reach it. Or a screen reader says only the word input, without saying what the field is for. Almost every beginner gets stuck on one of these three, and none of them show on screen when you test with a mouse.

This guide builds a keyboard-friendly page in seven steps. The numeric rules (4.5:1 and 3:1) were checked against the W3C page, the label wiring against MDN, and the test commands against the Next.js, Lighthouse and axe-core documentation. Two snippets, the skip link and the focus rule, are simplified versions of the code on this site, and every other example uses placeholder names.

Want the theory first, including the WCAG criteria behind each step? Follow the free lesson Web Accessibility Basics for Beginners in a Next.js Site in the Web app skills track. This post is the step-by-step practice.

What you need

  • A Next.js project using the App Router (the app folder) and TypeScript.
  • One page that already has links, buttons and a form, so there is something to fix.
  • Node.js and a terminal, to run ESLint and Lighthouse from the command line.
  • A desktop browser and a keyboard. Put the mouse aside while you test.

Step 1: Start from semantic HTML elements

A native element carries its own meaning. Without header, nav, main and footer, and headings in order, a page reads to a screen reader as one long block. Compare these two versions.

// Wrong: semuanya div. Tidak ada landmark, tidak ada heading.
<div className="top">Menu</div>
<div className="content">
  <div className="big-title">Panduan</div>
  <div className="sub-title">Bagian pertama</div>
</div>
// Right: elemen yang namanya sudah berarti sesuatu
// app/[locale]/layout.tsx (bagian body)
<body>
  <header>
    <nav>{/* link menu */}</nav>
  </header>
  <main id="main-content">{children}</main>
  <footer>{/* info situs */}</footer>
</body>

// app/[locale]/guide/page.tsx: satu h1, lalu h2, h3 berurutan
<h1>Panduan</h1>
<h2>Bagian pertama</h2>

In Next.js this is cheap: one main in the layout wrapping children, one h1 per page, then h2 and h3 in order. Give every page a unique title too. The Next.js documentation explains that its route announcer reads document.title first, then the h1 element, then the URL pathname, to tell assistive technology users that the page changed.

Step 2: Replace clickable divs with a button

This is the most common mistake. A div with onClick is not in the Tab order, does not respond to Enter or Space, and has no role. A real button element gives you all three for free, and its visible text automatically becomes its name.

// Wrong: div + onClick. Klik mouse jalan, tetapi Tab melewatinya,
// Enter tidak berbuat apa-apa, dan screen reader membacanya sebagai teks biasa.
<div className="chip" onClick={() => setActive(!active)}>
  Hanya yang baru
</div>
"use client"; // onClick butuh Client Component

import { useState } from "react";

export function FilterChip() {
  const [active, setActive] = useState(false);
  return (
    // Right: button asli. Tab, Enter, dan Spasi bekerja tanpa skrip tambahan.
    // aria-pressed memberi tahu screen reader apakah opsi ini sedang dipilih.
    <button
      type="button"
      className="chip"
      aria-pressed={active}
      onClick={() => setActive(!active)}
    >
      Hanya yang baru
    </button>
  );
}

// Untuk pindah halaman, pakai link, bukan button:
// <Link href="/guide">Buka panduan</Link>

Because onClick needs interaction in the browser, this component must be a Client Component. Keep only the small button as a Client Component, not the whole page. The splitting rule is explained in the difference between Server and Client Components in Next.js. One more rule of thumb: a link to go to another page, a button to perform an action.

Step 3: Label every input and give images alternative text

A label is connected to its input programmatically, not just placed near it. Per MDN, you make the connection by putting an id on the input and giving the label's for attribute the same value, and clicking the label moves focus to the input. In JSX, the for attribute is written htmlFor. A placeholder is not a substitute: its hint disappears once the user starts typing, so keep a visible label.

// Wrong: hanya placeholder. Saat pengguna mengetik, petunjuknya hilang,
// dan kolom ini tidak punya label yang terhubung secara programatis.
<input name="email" type="email" placeholder="Email kamu" />
// Right: label terhubung lewat htmlFor (atribut for di HTML) dan id
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />

// Gambar yang membawa informasi butuh alt yang menjelaskan isinya
// import Image from "next/image";
<Image src="/diagram-deploy.png" alt="Diagram alur deploy dari push ke server" width={720} height={400} />

An image that carries information needs alt text describing its content, not a word like image or photo. The Next.js documentation notes that eslint-plugin-jsx-a11y helps make sure img elements have alt text, so this gap can be caught early.

A keyboard user has to Tab through the whole header on every page. A skip link is the first link on the page and jumps focus to the main content. The version below is simplified from this site's SiteChrome component.

// app/components/SiteChrome.tsx (disederhanakan dari komponen situs ini)
function SkipLink() {
  return (
    <a
      href="#main-content"
      // sr-only: tersembunyi secara visual, tetapi tetap ada di urutan Tab.
      // focus:not-sr-only: muncul begitu menerima fokus.
      className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2"
    >
      Skip to main content
    </a>
  );
}

// Targetnya HARUS ada dengan id yang sama:
// <main id="main-content">{children}</main>

A skip link whose href is #main-content, with no element carrying the id main-content, does nothing visible. Keep the link and its target close together, then press Tab and Enter on a real page to confirm focus actually moves.

Next, the focus ring. The common failure is deleting the outline because it looks untidy. Restyle it instead of deleting it. One global :focus-visible rule gives every element a clear ring, and a given component can refine it.

/* app/globals.css: satu aturan global untuk semua elemen yang bisa difokuskan */
*:focus-visible {
  outline: 3px solid #facc15; /* warna ring: pilih yang kontras dengan latar */
  outline-offset: 2px;        /* jarak dari tepi supaya tidak menabrak border */
}

/* Wrong: ring dibuang demi tampilan rapi */
/* button { outline: none; } */

This site uses a thick 3 pixel outline with a 2 pixel offset for every element. Choose a ring colour that contrasts with your page background, and check it in the next step.

Step 5: Measure colour contrast, do not guess

According to the W3C explanation of criterion 1.4.3 (level AA), text needs a contrast ratio of at least 4.5:1, and large-scale text at least 3:1. Large-scale text starts at 18 point, or 14 point when bold. On a dark theme with translucent layers, the effective background is a blend, so judging by eye is often wrong.

/* Wrong: menilai kontras dengan mata di atas latar transparan */
.card {
  background: rgb(255 255 255 / 0.1); /* warna akhir = campuran dengan latar di belakangnya */
  color: var(--text-muted);
}

/* Right: ukur warna hasil render, lalu simpan nilainya di satu token */
:root {
  /* ubah sampai alat ukur menunjukkan minimal 4.5:1 untuk teks isi,
     atau 3:1 untuk teks besar (18 point, atau 14 point tebal) */
  --text-muted: #9ca3af;
}

Measure the final rendered colours with a contrast checker, available in browser developer tools and in many free tools, and measure again whenever a colour token changes. W3C also notes the ratios are thresholds that must not be rounded: 4.499:1 does not meet 4.5:1. This guide deliberately quotes no ratio for any particular palette, because a number only becomes a claim once somebody has measured it.

Step 6: Honour prefers-reduced-motion

Some visitors ask their operating system to reduce animation. MDN explains that the value reduce means the visitor has asked for an interface that removes, reduces or replaces motion-based animation. In CSS, use a media query. In JavaScript, ask the same media query through window.matchMedia before starting any scripted animation.

/* app/globals.css */
.panel {
  transition: transform 300ms ease, opacity 300ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .panel {
    transition-duration: 0.01ms; /* perubahan tetap terjadi, geraknya yang hilang */
  }
}
// CSS tidak bisa menghentikan animasi yang digerakkan skrip,
// jadi tanyakan media query yang sama dari JavaScript.
function prefersReducedMotion(): boolean {
  return (
    typeof window !== "undefined" && // aman saat render di server
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  );
}

Reduced motion does not mean reduced content. Replace movement with an instant change or a fade, and keep every piece of text, caption and control. The two snippets above are simplified versions of the pattern the interactive explainers on this site use.

Step 7: Test with the keyboard, ESLint, Lighthouse and axe-core

Start with the cheapest test: put the mouse down, click the address bar, then keep pressing Tab through the whole page. The skip link should appear on the first Tab, every control should show a ring, and nothing clickable should be skipped. After that, run these three commands.

# 1. Cari elemen non-native yang diberi onClick (kandidat button atau link)
grep -rn "<div[^>]*onClick" app
# Keluarannya berupa baris seperti: path/ke/file.tsx:12:  <div className="card" onClick=...
# Tidak ada keluaran berarti tidak ada yang cocok.

# 2. ESLint untuk folder yang kamu ubah (Next.js menyertakan eslint-plugin-jsx-a11y)
npx eslint app/components

# 3. Lighthouse, hanya kategori accessibility
npm install -g lighthouse
lighthouse http://localhost:3000/id/guide/ --only-categories=accessibility --view
# --view membuka laporan HTML di browser.
# Jalankan terhadap build produksi (npm run build && npm start), bukan npm run dev.

Mind the limits. Next.js ships eslint-plugin-jsx-a11y and its documentation lists example rules such as aria-props and role-has-required-aria-props, so do not assume ESLint catches a div with onClick. That is why the grep command above still earns its place. Lighthouse runs the accessibility category, and its README documents the --only-categories and --view flags.

# 4. axe-core sebagai dev dependency, untuk tes otomatis milikmu sendiri
npm install axe-core --save-dev
# Lalu panggil axe.run() di fixture tes setelah UI baru muncul,
# dan gagalkan tes bila hasilnya berisi violation.

The axe-core README says it finds on average 57 percent of WCAG issues automatically, and flags elements as incomplete when it is unsure and a manual review is needed. So a green score is not proof that your page is accessible. Listen to your page with a screen reader at least once, and do not treat the result as a certificate or a legal guarantee.

Common errors and how to fix them

Seven problems that most often appear when a beginner first makes a page accessible.

SymptomCauseFix
Tab skips a card or buttonThe element is a div or span with onClick, not a button or link.Replace it with a button of type button, or a link for navigation.
You cannot see where focus isoutline none was written with no replacement.Delete the rule and use one global :focus-visible rule with a thick outline.
The skip link exists, but Enter does not move focusThe target id does not exist, or is misspelled.Make sure main has exactly the same id as the href, then try Tab and Enter.
A screen reader says only edit text or inputThe input has only a placeholder, or for and id do not match.Add a label whose htmlFor equals the input id.
Faint text is hard to read on a bright screen or outdoorsContrast was judged by eye on a translucent background.Measure the rendered colour, aiming for 4.5:1 for body text and 3:1 for large text.
An error appears when onClick is added to a pageThe component using onClick is a Server Component.Move the button into a small file with use client on the first line.
All animation vanishes, information included, when reduce is onThe panel was hidden instead of swapped for an instant change.Shorten the transition or replace it with a fade, and keep the text visible.

Checklist before you publish

  • One h1 per page, headings in order, and one main in the layout.
  • Every clickable element is a real button or link.
  • Every input has a connected label, every informative image has alt text.
  • The skip link works and a focus ring is visible on every control.
  • Text contrast is measured: at least 4.5:1, or 3:1 for large text.
  • Tested with Tab, Lighthouse, axe-core and reduce motion switched on.

Go deeper

The free lesson explains the WCAG criteria behind these steps, with real code excerpts from this site, a quiz and one interactive explainer.

Web Accessibility Basics for Beginners in a Next.js Site

A fast page and an accessible page complement each other. Read Next.js performance optimizationand how to improve Core Web Vitals in Next.js for the speed side.

The rule to carry away: choose native elements first, keep focus visible, measure contrast, and ask for the motion preference before animating. None of it needs an extra library.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

How do I make a Next.js website accessible?

Start with semantic HTML: one main, one h1 per page, real buttons and links, and a label on every input. Then add a skip link, keep a visible :focus-visible ring, measure text contrast at 4.5:1 or better, and honour prefers-reduced-motion. Finish by tabbing through the page yourself.

Does Next.js make my site accessible automatically?

Only partly. Next.js includes a route announcer that reads the page title, the h1 or the pathname after client-side navigation, and it ships eslint-plugin-jsx-a11y. It cannot fix a div used as a button or low-contrast colours, so you still have to build and test those yourself.

Why does Tab skip my clickable card?

The card is probably a div or span with an onClick handler. Those elements are not in the Tab order and do not respond to Enter or Space. Use a real button for actions or a link for navigation, and keep the styling in CSS.

Can Lighthouse or axe-core prove my site is accessible?

No. The axe-core README says it finds on average 57 percent of WCAG issues automatically, and Lighthouse reports only its automated audits in the score. Use them as a first pass, then test with the keyboard and a screen reader.

What contrast ratio does my text need?

WCAG 2.2 criterion 1.4.3 at level AA asks for at least 4.5:1 for text and at least 3:1 for large-scale text, meaning 18 point or 14 point bold. The ratios are thresholds and must not be rounded. Measure the final rendered colours, especially over translucent backgrounds.