---
title: "Every rule vetd checks"
description: "The 92 Next.js and React rules vetd runs on every check, with why each matters and how to fix it."
url: http://localhost:4173/rules
updated: 2026-11-11
---

# Every rule vetd checks

> vetd runs 92 Next.js and React rules on every `check`, together with the TypeScript check. They find bugs TypeScript doesn't, for example server code in the browser bundle, Server Actions without an auth check, and pages without metadata. Rules that can't be sure report a warning instead of an error.

Each rule has its own page at `/rules/<id>.md`. The MCP tool `explain` returns the same text for a rule id.

## Index

| Rule id | Flags | Severity |
| --- | --- | --- |
| [`next/server-hooks`](http://localhost:4173/rules/next/server-hooks.md) | Client-only APIs in a Server Component | error |
| [`next/non-serializable-props`](http://localhost:4173/rules/next/non-serializable-props.md) | Non-serializable props from a Server to a Client Component | error or warning |
| [`next/server-only-in-client`](http://localhost:4173/rules/next/server-only-in-client.md) | Server-only module reachable from client code | error |
| [`next/server-actions`](http://localhost:4173/rules/next/server-actions.md) | Server Action shape | error or warning |
| [`next/route-params`](http://localhost:4173/rules/next/route-params.md) | params / searchParams types | error |
| [`next/unknown-route`](http://localhost:4173/rules/next/unknown-route.md) | Link/redirect to a route that does not exist | error or warning |
| [`next/client-env`](http://localhost:4173/rules/next/client-env.md) | Non-public env var in client code | error or warning |
| [`next/async-client-component`](http://localhost:4173/rules/next/async-client-component.md) | Async Client Component | error |
| [`next/segment-config`](http://localhost:4173/rules/next/segment-config.md) | Invalid route segment config | error or warning |
| [`next/parallel-route-default`](http://localhost:4173/rules/next/parallel-route-default.md) | Parallel route slot without default.tsx | error |
| [`next/middleware-to-proxy`](http://localhost:4173/rules/next/middleware-to-proxy.md) | middleware.ts in Next.js 16 | warning |
| [`next/no-amp`](http://localhost:4173/rules/next/no-amp.md) | AMP in Next.js 16 | error |
| [`next/revalidate-tag-profile`](http://localhost:4173/rules/next/revalidate-tag-profile.md) | revalidateTag without a cacheLife profile | warning |
| [`next/sync-dynamic-api`](http://localhost:4173/rules/next/sync-dynamic-api.md) | Synchronous cookies()/headers()/draftMode() | not set |
| [`next/no-head-in-app`](http://localhost:4173/rules/next/no-head-in-app.md) | next/head in the App Router | warning |
| [`next/server-action-auth`](http://localhost:4173/rules/next/server-action-auth.md) | Server Action writes without an auth check | warning |
| [`next/module-state`](http://localhost:4173/rules/next/module-state.md) | Mutable module state in server code | warning |
| [`next/sequential-await`](http://localhost:4173/rules/next/sequential-await.md) | Independent awaits in sequence | warning |
| [`next/floating-promise`](http://localhost:4173/rules/next/floating-promise.md) | Unawaited async call in a Server Action or route handler | warning |
| [`next/async-effect`](http://localhost:4173/rules/next/async-effect.md) | Async effect callback | error |
| [`next/fetch-in-effect`](http://localhost:4173/rules/next/fetch-in-effect.md) | Data fetching in an effect | warning |
| [`next/effect-cleanup`](http://localhost:4173/rules/next/effect-cleanup.md) | Effect subscribes without cleanup | warning |
| [`next/missing-metadata`](http://localhost:4173/rules/next/missing-metadata.md) | Page without metadata | warning |
| [`next/img-alt`](http://localhost:4173/rules/next/img-alt.md) | Image without alt | warning |
| [`next/html-lang`](http://localhost:4173/rules/next/html-lang.md) | <html> without lang | warning |
| [`next/internal-anchor`](http://localhost:4173/rules/next/internal-anchor.md) | <a> for an internal route | warning |
| [`next/json-ld-xss`](http://localhost:4173/rules/next/json-ld-xss.md) | Unescaped JSON in a <script> tag | error |
| [`ui/viewport-zoom-lock`](http://localhost:4173/rules/ui/viewport-zoom-lock.md) | Viewport blocks zoom | error |
| [`ui/input-font-size`](http://localhost:4173/rules/ui/input-font-size.md) | Input text under 16px on mobile | warning |
| [`ui/video-autoplay`](http://localhost:4173/rules/ui/video-autoplay.md) | Autoplay video missing muted/playsInline | warning |
| [`ui/click-on-non-interactive`](http://localhost:4173/rules/ui/click-on-non-interactive.md) | Click handler on a non-interactive element | warning |
| [`ui/icon-button-label`](http://localhost:4173/rules/ui/icon-button-label.md) | Icon-only button without a label | warning |
| [`ui/outline-none`](http://localhost:4173/rules/ui/outline-none.md) | Focus outline removed without replacement | warning |
| [`ui/transition-all`](http://localhost:4173/rules/ui/transition-all.md) | transition-all | warning |
| [`ui/press-scale`](http://localhost:4173/rules/ui/press-scale.md) | Press scale below 0.95 | warning |
| [`ui/hover-font-weight`](http://localhost:4173/rules/ui/hover-font-weight.md) | Font weight changes on interaction | warning |
| [`ui/theme-switch-transitions`](http://localhost:4173/rules/ui/theme-switch-transitions.md) | Theme switch animates every color | warning |
| [`ui/validate-late`](http://localhost:4173/rules/ui/validate-late.md) | Form validates on every keystroke | warning |
| [`ui/input-type`](http://localhost:4173/rules/ui/input-type.md) | Text input for an email/phone/url/password field | warning |
| [`ui/animate-layout`](http://localhost:4173/rules/ui/animate-layout.md) | Animating layout properties | warning |
| [`ui/will-change`](http://localhost:4173/rules/ui/will-change.md) | will-change on a property it can't help | warning |
| [`ui/user-select-page`](http://localhost:4173/rules/ui/user-select-page.md) | Text selection disabled for the page | warning |
| [`ui/page-scrollbar`](http://localhost:4173/rules/ui/page-scrollbar.md) | Page scrollbar restyled or hidden | warning |
| [`ui/reduced-motion`](http://localhost:4173/rules/ui/reduced-motion.md) | Keyframes without a reduced-motion fallback | warning |
| [`shadcn/nested-trigger`](http://localhost:4173/rules/shadcn/nested-trigger.md) | Button inside a trigger without asChild | error |
| [`shadcn/as-child-single-element`](http://localhost:4173/rules/shadcn/as-child-single-element.md) | asChild without exactly one element child | error |
| [`shadcn/dialog-title`](http://localhost:4173/rules/shadcn/dialog-title.md) | Dialog without a title | warning |
| [`shadcn/base-ui-as-child`](http://localhost:4173/rules/shadcn/base-ui-as-child.md) | asChild in a Base UI project | error |
| [`tailwind/dynamic-class`](http://localhost:4173/rules/tailwind/dynamic-class.md) | Class name built at runtime | warning |
| [`tailwind/conflicting-classes`](http://localhost:4173/rules/tailwind/conflicting-classes.md) | Conflicting utilities in one class list | warning |
| [`tailwind/removed-in-v4`](http://localhost:4173/rules/tailwind/removed-in-v4.md) | Utility removed in Tailwind 4 | warning |
| [`tailwind/hover-capability`](http://localhost:4173/rules/tailwind/hover-capability.md) | Tailwind 3 hover styles stick on touch | warning |
| [`tailwind/arbitrary-z-index`](http://localhost:4173/rules/tailwind/arbitrary-z-index.md) | Arbitrary z-index | warning |
| [`perf/font-link`](http://localhost:4173/rules/perf/font-link.md) | Web font loaded from a font CDN | warning |
| [`perf/font-display`](http://localhost:4173/rules/perf/font-display.md) | Text invisible while the font loads | warning |
| [`perf/font-format`](http://localhost:4173/rules/perf/font-format.md) | @font-face without woff2 | warning |
| [`perf/font-subsets`](http://localhost:4173/rules/perf/font-subsets.md) | next/font/google without subsets | error |
| [`perf/font-module-scope`](http://localhost:4173/rules/perf/font-module-scope.md) | next/font loader not called at module scope | error |
| [`perf/font-weights`](http://localhost:4173/rules/perf/font-weights.md) | Too many font weights | warning |
| [`perf/font-duplicate`](http://localhost:4173/rules/perf/font-duplicate.md) | Same font loaded in several modules | warning |
| [`perf/img-element`](http://localhost:4173/rules/perf/img-element.md) | Raw <img> in a Next.js app | warning |
| [`perf/image-size`](http://localhost:4173/rules/perf/image-size.md) | Image without dimensions | error or warning |
| [`perf/image-sizes`](http://localhost:4173/rules/perf/image-sizes.md) | Responsive image without sizes | warning |
| [`perf/image-priority`](http://localhost:4173/rules/perf/image-priority.md) | priority / lazy loading conflicts | warning |
| [`perf/sync-script`](http://localhost:4173/rules/perf/sync-script.md) | Render-blocking <script> | warning |
| [`perf/script-strategy`](http://localhost:4173/rules/perf/script-strategy.md) | Script loading strategy | warning |
| [`perf/client-page`](http://localhost:4173/rules/perf/client-page.md) | "use client" on a page or layout | warning |
| [`perf/heavy-import`](http://localhost:4173/rules/perf/heavy-import.md) | Heavy library in the first-load bundle | warning |
| [`perf/dynamic-ssr-false`](http://localhost:4173/rules/perf/dynamic-ssr-false.md) | next/dynamic ssr: false in a Server Component | error |
| [`data/await-in-loop`](http://localhost:4173/rules/data/await-in-loop.md) | Await inside a loop | warning |
| [`data/self-fetch`](http://localhost:4173/rules/data/self-fetch.md) | Server code fetching its own API route | warning |
| [`data/client-fetch-waterfall`](http://localhost:4173/rules/data/client-fetch-waterfall.md) | Dependent client-side fetches | warning |
| [`data/no-loading-boundary`](http://localhost:4173/rules/data/no-loading-boundary.md) | Dynamic page without a loading boundary | warning |
| [`data/force-dynamic-layout`](http://localhost:4173/rules/data/force-dynamic-layout.md) | Layout forces dynamic rendering | warning |
| [`data/uncached-fetch`](http://localhost:4173/rules/data/uncached-fetch.md) | Constant fetch uncached on a dynamic route | warning |
| [`data/serial-route-fetch`](http://localhost:4173/rules/data/serial-route-fetch.md) | Same query in generateMetadata and the page | warning |
| [`seo/meta-description`](http://localhost:4173/rules/seo/meta-description.md) | No meta description | warning |
| [`seo/link-text`](http://localhost:4173/rules/seo/link-text.md) | Non-descriptive link text | warning |
| [`seo/crawlable-link`](http://localhost:4173/rules/seo/crawlable-link.md) | Link crawlers can't follow | warning |
| [`seo/noindex`](http://localhost:4173/rules/seo/noindex.md) | Root layout blocks indexing | warning |
| [`a11y/link-name`](http://localhost:4173/rules/a11y/link-name.md) | Link without a name | error |
| [`a11y/button-name`](http://localhost:4173/rules/a11y/button-name.md) | Empty button | error |
| [`a11y/form-label`](http://localhost:4173/rules/a11y/form-label.md) | Form field without a label | error |
| [`a11y/tabindex-positive`](http://localhost:4173/rules/a11y/tabindex-positive.md) | Positive tabIndex | warning |
| [`a11y/aria-hidden-focus`](http://localhost:4173/rules/a11y/aria-hidden-focus.md) | Focusable element under aria-hidden | error |
| [`a11y/iframe-title`](http://localhost:4173/rules/a11y/iframe-title.md) | Iframe without a title | error |
| [`a11y/heading-skip`](http://localhost:4173/rules/a11y/heading-skip.md) | Skipped heading level | warning |
| [`a11y/dup-id`](http://localhost:4173/rules/a11y/dup-id.md) | Duplicate id | warning |
| [`bp/document-write`](http://localhost:4173/rules/bp/document-write.md) | document.write() | warning |
| [`bp/unload-listener`](http://localhost:4173/rules/bp/unload-listener.md) | unload listener | warning |
| [`bp/passive-listener`](http://localhost:4173/rules/bp/passive-listener.md) | Non-passive scroll listener | warning |
| [`bp/http-url`](http://localhost:4173/rules/bp/http-url.md) | Plain-HTTP resource | warning |

## Server and client boundaries

### next/server-hooks

**Client-only APIs in a Server Component.** Severity: error. Page: [`next/server-hooks`](http://localhost:4173/rules/next/server-hooks.md)

Server Components render once on the server: React state/effect hooks, Next navigation hooks and browser globals (window, document, localStorage…) do not exist there. Next.js fails the build for hooks and the render crashes for browser globals.

Fix: Move the interactive part into a "use client" component and render it from the Server Component, or guard the browser access with `typeof window !== "undefined"`.

### next/non-serializable-props

**Non-serializable props from a Server to a Client Component.** Severity: error or warning. Page: [`next/non-serializable-props`](http://localhost:4173/rules/next/non-serializable-props.md)

Props crossing the server→client boundary are serialized by React Flight. Functions (other than Server Actions), class instances and event handlers cannot be serialized; the render throws at runtime.

Fix: Pass plain data, make the function a Server Action ("use server"), or move the handler into the Client Component.

### next/server-only-in-client

**Server-only module reachable from client code.** Severity: error. Page: [`next/server-only-in-client`](http://localhost:4173/rules/next/server-only-in-client.md)

Everything a "use client" module imports (transitively, at runtime) is bundled for the browser. Modules marked `server-only`, `next/headers`, Node built-ins or configured server modules (DB clients, secrets) either break the build or leak server code.

Fix: Break the chain shown in `chain`: import a type-only binding (`import type`), move the server call into a Server Component/Action, or split the module.

### next/server-actions

**Server Action shape.** Severity: error or warning. Page: [`next/server-actions`](http://localhost:4173/rules/next/server-actions.md)

A "use server" module may only export async functions; inline actions cannot live in Client Components; action arguments are serialized so they cannot be functions; a <form action> in a Server Component must be a Server Action.

Fix: Make the export async (or move non-action exports elsewhere), move inline actions to a "use server" file, take serializable arguments, or add "use server" to the function body.

### next/route-params

**params / searchParams types.** Severity: error. Page: [`next/route-params`](http://localhost:4173/rules/next/route-params.md)

Since Next.js 15, `params` and `searchParams` are Promises, and their keys come from the route's dynamic segments ([id] → string, [...slug] → string[]).

Fix: Type them as `Promise<{ … }>` with exactly the route's segment names, or use the generated `PageProps<"/route">` helper.

### next/unknown-route

**Link/redirect to a route that does not exist.** Severity: error or warning. Page: [`next/unknown-route`](http://localhost:4173/rules/next/unknown-route.md)

The target path matches no app route (page, route handler, metadata route) and no file in public/, so it 404s.

Fix: Fix the path, or create the route. If the proxy/middleware serves it, ignore this finding.

### next/client-env

**Non-public env var in client code.** Severity: error or warning. Page: [`next/client-env`](http://localhost:4173/rules/next/client-env.md)

Only NEXT_PUBLIC_* variables are inlined into the browser bundle; any other process.env read is undefined there (and naming a secret in client code is a leak risk).

Fix: Read it on the server and pass the value down, or rename it NEXT_PUBLIC_ if it is safe to expose.

### next/async-client-component

**Async Client Component.** Severity: error. Page: [`next/async-client-component`](http://localhost:4173/rules/next/async-client-component.md)

async/await components are only supported on the server; an async Client Component never resolves on the client.

Fix: Remove "use client" (make it a Server Component) or fetch inside a Server Component / with `use()`.

### next/segment-config

**Invalid route segment config.** Severity: error or warning. Page: [`next/segment-config`](http://localhost:4173/rules/next/segment-config.md)

Next.js reads `dynamic`, `revalidate`, `runtime`, `fetchCache`, `dynamicParams`, `preferredRegion` and `maxDuration` statically from page/layout/route files and only accepts literal values from a fixed set; with cacheComponents enabled, dynamic/revalidate/fetchCache are rejected.

Fix: Use one of the allowed literal values, or switch to "use cache" with cacheComponents.

## Next.js 16 upgrade

### next/parallel-route-default

**Parallel route slot without default.tsx.** Severity: error. Page: [`next/parallel-route-default`](http://localhost:4173/rules/next/parallel-route-default.md)

Since Next.js 16 every @slot needs an explicit default.js; the build fails without one.

Fix: Add default.tsx to the slot (return null, or notFound(), for slots with nothing to show).

### next/middleware-to-proxy

**middleware.ts in Next.js 16.** Severity: warning. Page: [`next/middleware-to-proxy`](http://localhost:4173/rules/next/middleware-to-proxy.md)

Next.js 16 renamed middleware to proxy (Node.js runtime); middleware is deprecated.

Fix: Rename middleware.ts to proxy.ts and the exported function to `proxy` (the codemod does both).

### next/no-amp

**AMP in Next.js 16.** Severity: error. Page: [`next/no-amp`](http://localhost:4173/rules/next/no-amp.md)

AMP support (next/amp, `config.amp`) was removed in Next.js 16.

Fix: Remove the AMP config and imports; serve the regular page.

### next/revalidate-tag-profile

**revalidateTag without a cacheLife profile.** Severity: warning. Page: [`next/revalidate-tag-profile`](http://localhost:4173/rules/next/revalidate-tag-profile.md)

In Next.js 16 the single-argument revalidateTag(tag) is deprecated: it needs a cacheLife profile for stale-while-revalidate behavior.

Fix: revalidateTag(tag, "max"), or updateTag(tag) in a Server Action when the user must read their own write.

### next/sync-dynamic-api

**Synchronous cookies()/headers()/draftMode().** Severity: not set. Page: [`next/sync-dynamic-api`](http://localhost:4173/rules/next/sync-dynamic-api.md)

These return Promises since Next.js 15, and synchronous access was removed in 16: the property read happens on a Promise.

Fix: Await first: `(await cookies()).get("name")`.

### next/no-head-in-app

**next/head in the App Router.** Severity: warning. Page: [`next/no-head-in-app`](http://localhost:4173/rules/next/no-head-in-app.md)

next/head is a Pages Router API; in app/ it renders nothing into <head>.

Fix: Export `metadata` or `generateMetadata` from the page or layout.

## Server code: security and speed

### next/server-action-auth

**Server Action writes without an auth check.** Severity: warning. Page: [`next/server-action-auth`](http://localhost:4173/rules/next/server-action-auth.md)

Every exported Server Action is a public POST endpoint that anyone can call with any arguments; the page that renders the form is not a guard.

Fix: Check the session/user (and that they own the record) at the top of the action, before the write.

### next/module-state

**Mutable module state in server code.** Severity: warning. Page: [`next/module-state`](http://localhost:4173/rules/next/module-state.md)

A module-level variable lives as long as the server instance and is shared by every concurrent request: one user's data can leak into another's response.

Fix: Keep per-request data in arguments, cookies or React cache(); use a real store for shared state.

### next/sequential-await

**Independent awaits in sequence.** Severity: warning. Page: [`next/sequential-await`](http://localhost:4173/rules/next/sequential-await.md)

Each await waits for the previous one even though it doesn't use its result: the latencies add up (a waterfall) on every request.

Fix: Start both, then await together: `const [a, b] = await Promise.all([readA(), readB()])`.

### next/floating-promise

**Unawaited async call in a Server Action or route handler.** Severity: warning. Page: [`next/floating-promise`](http://localhost:4173/rules/next/floating-promise.md)

Once the response is sent, serverless platforms freeze or stop the function: work nobody awaits may never finish, and its errors are lost.

Fix: Await it, or schedule it with after() from next/server to run after the response.

## React client code

### next/async-effect

**Async effect callback.** Severity: error. Page: [`next/async-effect`](http://localhost:4173/rules/next/async-effect.md)

React treats an effect's return value as its cleanup; an async callback returns a Promise, so cleanup never runs and errors go unhandled.

Fix: Define an async function inside the effect and call it; return a real cleanup function.

### next/fetch-in-effect

**Data fetching in an effect.** Severity: warning. Page: [`next/fetch-in-effect`](http://localhost:4173/rules/next/fetch-in-effect.md)

Fetching in useEffect runs after paint, twice in development, races on fast re-renders and creates client-side waterfalls; the App Router can fetch on the server instead.

Fix: Fetch in a Server Component and pass the data down, or use SWR / React Query in the client.

### next/effect-cleanup

**Effect subscribes without cleanup.** Severity: warning. Page: [`next/effect-cleanup`](http://localhost:4173/rules/next/effect-cleanup.md)

Listeners, timers and observers created in an effect outlive the component and pile up each time the effect re-runs.

Fix: Return a function that removes the listener / clears the timer / disconnects the observer.

## SEO, accessibility and security

### next/missing-metadata

**Page without metadata.** Severity: warning. Page: [`next/missing-metadata`](http://localhost:4173/rules/next/missing-metadata.md)

No metadata on the page or any layout above it: the page ships without a <title> or description, which hurts search ranking and link previews.

Fix: Export `metadata` (or `generateMetadata`) from the page, or a title template from a layout.

### next/img-alt

**Image without alt.** Severity: warning. Page: [`next/img-alt`](http://localhost:4173/rules/next/img-alt.md)

Screen readers fall back to the file name and search engines can't understand the image.

Fix: Describe the image in alt, or use alt="" when it is decorative.

### next/html-lang

**<html> without lang.** Severity: warning. Page: [`next/html-lang`](http://localhost:4173/rules/next/html-lang.md)

The document language drives screen-reader pronunciation, translation prompts and search language targeting.

Fix: Set it in the root layout: <html lang="en">.

### next/internal-anchor

**<a> for an internal route.** Severity: warning. Page: [`next/internal-anchor`](http://localhost:4173/rules/next/internal-anchor.md)

A plain anchor reloads the whole app and skips prefetching; <Link> navigates client-side.

Fix: Use <Link href="…"> from next/link (keep <a> for downloads and external URLs).

### next/json-ld-xss

**Unescaped JSON in a <script> tag.** Severity: error. Page: [`next/json-ld-xss`](http://localhost:4173/rules/next/json-ld-xss.md)

JSON.stringify does not escape `</script>`: any string in the data containing it ends the script element and the rest runs as HTML (stored XSS through JSON-LD).

Fix: Escape `<`: JSON.stringify(data).replace(/</g, "\\u003c").

## Interface craft: accessibility, motion and mobile

### ui/viewport-zoom-lock

**Viewport blocks zoom.** Severity: error. Page: [`ui/viewport-zoom-lock`](http://localhost:4173/rules/ui/viewport-zoom-lock.md)

maximumScale ≤ 1 or userScalable: false stops low-vision users from pinch-zooming (WCAG 1.4.4).

Fix: Remove maximumScale and userScalable from the viewport export or meta tag.

### ui/input-font-size

**Input text under 16px on mobile.** Severity: warning. Page: [`ui/input-font-size`](http://localhost:4173/rules/ui/input-font-size.md)

iOS Safari zooms the page when an input under 16px gets focus, and doesn't zoom back.

Fix: Use text-base on small screens: text-base md:text-sm.

### ui/video-autoplay

**Autoplay video missing muted/playsInline.** Severity: warning. Page: [`ui/video-autoplay`](http://localhost:4173/rules/ui/video-autoplay.md)

iOS only autoplays muted video, and without playsInline it takes over the screen in the fullscreen player.

Fix: <video autoPlay muted playsInline loop>.

### ui/click-on-non-interactive

**Click handler on a non-interactive element.** Severity: warning. Page: [`ui/click-on-non-interactive`](http://localhost:4173/rules/ui/click-on-non-interactive.md)

A <div onClick> can't be focused or activated by keyboard and isn't announced as a control.

Fix: Use a <button type="button">, or add role, tabIndex={0} and an onKeyDown for Enter/Space.

### ui/icon-button-label

**Icon-only button without a label.** Severity: warning. Page: [`ui/icon-button-label`](http://localhost:4173/rules/ui/icon-button-label.md)

Screen readers announce "button" with no name.

Fix: Add aria-label (or a sr-only span) describing the action.

### ui/outline-none

**Focus outline removed without replacement.** Severity: warning. Page: [`ui/outline-none`](http://localhost:4173/rules/ui/outline-none.md)

Keyboard users can't see where focus is.

Fix: Pair outline-none with a focus-visible style, e.g. focus-visible:ring-2.

### ui/transition-all

**transition-all.** Severity: warning. Page: [`ui/transition-all`](http://localhost:4173/rules/ui/transition-all.md)

Every changing property animates, including layout and theme colors, and the browser can't skip work.

Fix: Name the properties: transition-colors, transition-transform, transition-[transform,opacity].

### ui/press-scale

**Press scale below 0.95.** Severity: warning. Page: [`ui/press-scale`](http://localhost:4173/rules/ui/press-scale.md)

Larger press shrinks look cartoonish rather than responsive.

Fix: Use active:scale-[0.97] (or whileTap={{ scale: 0.97 }}).

### ui/hover-font-weight

**Font weight changes on interaction.** Severity: warning. Page: [`ui/hover-font-weight`](http://localhost:4173/rules/ui/hover-font-weight.md)

Bolder text is wider: the label reflows and its neighbours jump.

Fix: Change color or background instead, or reserve the bold width with a hidden copy.

### ui/theme-switch-transitions

**Theme switch animates every color.** Severity: warning. Page: [`ui/theme-switch-transitions`](http://localhost:4173/rules/ui/theme-switch-transitions.md)

Without disableTransitionOnChange each transitioned element fades to the new theme separately.

Fix: <ThemeProvider disableTransitionOnChange>.

### ui/validate-late

**Form validates on every keystroke.** Severity: warning. Page: [`ui/validate-late`](http://localhost:4173/rules/ui/validate-late.md)

Errors appear before the user finished typing.

Fix: Keep react-hook-form's default mode, or use "onBlur"/"onTouched".

### ui/input-type

**Text input for an email/phone/url/password field.** Severity: warning. Page: [`ui/input-type`](http://localhost:4173/rules/ui/input-type.md)

The wrong type gives the wrong mobile keyboard and weaker validation and autofill.

Fix: Use type="email", "tel", "url" or "password".

### ui/animate-layout

**Animating layout properties.** Severity: warning. Page: [`ui/animate-layout`](http://localhost:4173/rules/ui/animate-layout.md)

width, height, top/left, margin and padding run layout on every frame, and long pages drop frames.

Fix: Animate transform and opacity; for height use grid-template-rows 0fr → 1fr.

### ui/will-change

**will-change on a property it can't help.** Severity: warning. Page: [`ui/will-change`](http://localhost:4173/rules/ui/will-change.md)

will-change only promotes transform, opacity, filter and clip-path; `all` or layout properties waste GPU memory.

Fix: will-change: transform (or remove it).

### ui/user-select-page

**Text selection disabled for the page.** Severity: warning. Page: [`ui/user-select-page`](http://localhost:4173/rules/ui/user-select-page.md)

People copy addresses, error messages and order numbers; that is content.

Fix: Scope user-select: none to controls (buttons, drag handles).

### ui/page-scrollbar

**Page scrollbar restyled or hidden.** Severity: warning. Page: [`ui/page-scrollbar`](http://localhost:4173/rules/ui/page-scrollbar.md)

The native page scrollbar is how people see where they are and how much is left.

Fix: Style scrollbars only on small inner scroll areas.

### ui/reduced-motion

**Keyframes without a reduced-motion fallback.** Severity: warning. Page: [`ui/reduced-motion`](http://localhost:4173/rules/ui/reduced-motion.md)

People with vestibular disorders turn on reduced motion; the animation ignores it.

Fix: Add @media (prefers-reduced-motion: reduce) that stops or simplifies the animations.

## shadcn/ui and Radix composition

### shadcn/nested-trigger

**Button inside a trigger without asChild.** Severity: error. Page: [`shadcn/nested-trigger`](http://localhost:4173/rules/shadcn/nested-trigger.md)

Radix triggers render a <button>: a button inside it is invalid HTML, a hydration error and a double tab stop.

Fix: Add asChild to the trigger so it merges into the child.

### shadcn/as-child-single-element

**asChild without exactly one element child.** Severity: error. Page: [`shadcn/as-child-single-element`](http://localhost:4173/rules/shadcn/as-child-single-element.md)

Slot clones a single child; text, fragments or several children throw React.Children.only.

Fix: Wrap the children in one element.

### shadcn/dialog-title

**Dialog without a title.** Severity: warning. Page: [`shadcn/dialog-title`](http://localhost:4173/rules/shadcn/dialog-title.md)

The dialog has no accessible name and Radix logs an error.

Fix: Add a DialogTitle (wrap it in VisuallyHidden to hide it visually).

### shadcn/base-ui-as-child

**asChild in a Base UI project.** Severity: error. Page: [`shadcn/base-ui-as-child`](http://localhost:4173/rules/shadcn/base-ui-as-child.md)

Base UI components compose through the render prop; asChild is ignored.

Fix: Use render={<Link href="…" />}.

## Tailwind CSS

### tailwind/dynamic-class

**Class name built at runtime.** Severity: warning. Page: [`tailwind/dynamic-class`](http://localhost:4173/rules/tailwind/dynamic-class.md)

Tailwind generates CSS only for class names it finds whole in the source: `bg-${color}-500` has no styles.

Fix: Map values to full class names: { red: "bg-red-500", … }[color].

### tailwind/conflicting-classes

**Conflicting utilities in one class list.** Severity: warning. Page: [`tailwind/conflicting-classes`](http://localhost:4173/rules/tailwind/conflicting-classes.md)

Two utilities setting the same property: the winner is decided by stylesheet order, not by the order written.

Fix: Keep one; for overrides, merge with cn()/tailwind-merge.

### tailwind/removed-in-v4

**Utility removed in Tailwind 4.** Severity: warning. Page: [`tailwind/removed-in-v4`](http://localhost:4173/rules/tailwind/removed-in-v4.md)

The class generates no CSS in Tailwind 4, so the style silently disappears.

Fix: Use the replacement named in the message.

### tailwind/hover-capability

**Tailwind 3 hover styles stick on touch.** Severity: warning. Page: [`tailwind/hover-capability`](http://localhost:4173/rules/tailwind/hover-capability.md)

Without hoverOnlyWhenSupported, hover: applies on tap and stays until the next tap on phones (Tailwind 4 does this by default).

Fix: tailwind.config: future: { hoverOnlyWhenSupported: true }.

### tailwind/arbitrary-z-index

**Arbitrary z-index.** Severity: warning. Page: [`tailwind/arbitrary-z-index`](http://localhost:4173/rules/tailwind/arbitrary-z-index.md)

z-[9999]-style values escalate into stacking wars between overlays.

Fix: Define a small z-index scale in the theme and use its steps.

## Performance: fonts, images, scripts and bundle

### perf/font-link

**Web font loaded from a font CDN.** Severity: warning. Page: [`perf/font-link`](http://localhost:4173/rules/perf/font-link.md)

A <link> or @import to Google Fonts/Typekit is render-blocking CSS from another origin, and the font files then come from a third origin: text paints late and swaps in (layout shift).

Fix: Load the font with next/font/google or next/font/local: self-hosted, preloaded, with a size-adjusted fallback.

### perf/font-display

**Text invisible while the font loads.** Severity: warning. Page: [`perf/font-display`](http://localhost:4173/rules/perf/font-display.md)

font-display: block (or auto, or no font-display in @font-face) hides text for up to 3s while the web font downloads (Lighthouse: "Ensure text remains visible during webfont load").

Fix: font-display: swap (or optional for body text); in next/font, keep the default display: "swap".

### perf/font-format

**@font-face without woff2.** Severity: warning. Page: [`perf/font-format`](http://localhost:4173/rules/perf/font-format.md)

woff2 is about 30% smaller than woff and much smaller than ttf/otf, and every current browser supports it.

Fix: Convert the font to woff2 and list it first in src (or load it with next/font/local).

### perf/font-subsets

**next/font/google without subsets.** Severity: error. Page: [`perf/font-subsets`](http://localhost:4173/rules/perf/font-subsets.md)

With preload on (the default), Next.js fails the build when no subsets are given: it can't know which subset files to preload.

Fix: Add subsets: ["latin"] (the subsets your text uses), or preload: false.

### perf/font-module-scope

**next/font loader not called at module scope.** Severity: error. Page: [`perf/font-module-scope`](http://localhost:4173/rules/perf/font-module-scope.md)

next/font is resolved at build time: the loader must be called at the top level of the module and assigned to a const, or the build fails.

Fix: const font = Inter({ subsets: ["latin"] }) at module scope, then use font.className in components.

### perf/font-weights

**Too many font weights.** Severity: warning. Page: [`perf/font-weights`](http://localhost:4173/rules/perf/font-weights.md)

Each static weight is a separate font file to download; for a variable font, listing weights replaces one file that has every weight with several static ones.

Fix: Keep the 2-3 weights the design uses, or drop weight for a variable font.

### perf/font-duplicate

**Same font loaded in several modules.** Severity: warning. Page: [`perf/font-duplicate`](http://localhost:4173/rules/perf/font-duplicate.md)

Every next/font call is hosted as its own instance: the same family loaded in two modules is downloaded twice.

Fix: Load each font once in a shared module (e.g. app/fonts.ts) and import the font object where you need it.

### perf/img-element

**Raw <img> in a Next.js app.** Severity: warning. Page: [`perf/img-element`](http://localhost:4173/rules/perf/img-element.md)

A plain <img> ships the original file at full size, with no responsive srcset, AVIF/WebP or lazy loading (Lighthouse: properly size images, modern formats, offscreen images).

Fix: Use <Image> from next/image (configure images.remotePatterns for remote hosts).

### perf/image-size

**Image without dimensions.** Severity: error or warning. Page: [`perf/image-size`](http://localhost:4173/rules/perf/image-size.md)

next/image throws at runtime without width and height (unless fill or a static import); a raw <img> without them shifts the layout when it loads (CLS).

Fix: Set width and height (the intrinsic size), use fill inside a sized container, or import the image statically.

### perf/image-sizes

**Responsive image without sizes.** Severity: warning. Page: [`perf/image-sizes`](http://localhost:4173/rules/perf/image-sizes.md)

Without sizes, a fill image's srcset assumes 100vw and a stretched fixed-width image only offers 1x/2x files: phones download desktop-sized images.

Fix: Add sizes describing the rendered width, e.g. sizes="(min-width: 768px) 50vw, 100vw".

### perf/image-priority

**priority / lazy loading conflicts.** Severity: warning. Page: [`perf/image-priority`](http://localhost:4173/rules/perf/image-priority.md)

Next.js 16 deprecated priority in favor of preload; and priority or preload together with loading="lazy" contradict each other.

Fix: Use preload (or loading="eager" with fetchPriority="high") on the LCP image only, without loading="lazy".

### perf/sync-script

**Render-blocking <script>.** Severity: warning. Page: [`perf/sync-script`](http://localhost:4173/rules/perf/sync-script.md)

A <script src> without async or defer stops HTML parsing until it downloads and runs (Lighthouse: eliminate render-blocking resources).

Fix: Use next/script (afterInteractive or lazyOnload), or add defer.

### perf/script-strategy

**Script loading strategy.** Severity: warning. Page: [`perf/script-strategy`](http://localhost:4173/rules/perf/script-strategy.md)

beforeInteractive only works in the root layout (or pages/_document); tag managers and pixels loaded afterInteractive compete with hydration and inflate Total Blocking Time.

Fix: Move beforeInteractive scripts to the root layout; load analytics with @next/third-parties or strategy="lazyOnload" (or worker).

### perf/client-page

**"use client" on a page or layout.** Severity: warning. Page: [`perf/client-page`](http://localhost:4173/rules/perf/client-page.md)

The whole route becomes client JavaScript: nothing in it renders on the server, its data is fetched after hydration and Total Blocking Time grows.

Fix: Keep the page/layout a Server Component and move "use client" down to the interactive leaves.

### perf/heavy-import

**Heavy library in the first-load bundle.** Severity: warning. Page: [`perf/heavy-import`](http://localhost:4173/rules/perf/heavy-import.md)

A large library imported statically into client code on a route is downloaded, parsed and executed before the page is interactive.

Fix: Load the widget with next/dynamic (below the fold), import only the part you need, or use a lighter alternative (Intl/date-fns for moment).

### perf/dynamic-ssr-false

**next/dynamic ssr: false in a Server Component.** Severity: error. Page: [`perf/dynamic-ssr-false`](http://localhost:4173/rules/perf/dynamic-ssr-false.md)

Next.js fails the build: `ssr: false` is not allowed with next/dynamic in Server Components.

Fix: Move the dynamic() call into a "use client" component and render that from the server.

## Data fetching

### data/await-in-loop

**Await inside a loop.** Severity: warning. Page: [`data/await-in-loop`](http://localhost:4173/rules/data/await-in-loop.md)

Each iteration waits for the previous round trip before starting its own: N items cost N sequential database or network latencies on the request path.

Fix: Start the calls together and await them once (`await Promise.all(items.map(load))`), or fetch them all in one batched query (`where: { id: { in: ids } }`).

### data/self-fetch

**Server code fetching its own API route.** Severity: warning. Page: [`data/self-fetch`](http://localhost:4173/rules/data/self-fetch.md)

A Server Component, Server Action or route handler that fetches this app's /api route makes an extra HTTP hop through the network and a second serverless invocation, and a relative URL fails on the server.

Fix: Import and call the function behind the route handler directly.

### data/client-fetch-waterfall

**Dependent client-side fetches.** Severity: warning. Page: [`data/client-fetch-waterfall`](http://localhost:4173/rules/data/client-fetch-waterfall.md)

A SWR/React Query hook whose key or `enabled` waits for another hook's data starts only after the first request finishes, and both start only after hydration: two serial round trips before the content shows.

Fix: Fetch on the server and pass the data down, or serve both from one endpoint.

### data/no-loading-boundary

**Dynamic page without a loading boundary.** Severity: warning. Page: [`data/no-loading-boundary`](http://localhost:4173/rules/data/no-loading-boundary.md)

A page rendered per request that awaits data, with no loading.tsx or <Suspense> above it, sends nothing until its slowest await resolves: TTFB and FCP wait for the database.

Fix: Add loading.tsx to the segment, or move the slow part into an async component wrapped in <Suspense>.

### data/force-dynamic-layout

**Layout forces dynamic rendering.** Severity: warning. Page: [`data/force-dynamic-layout`](http://localhost:4173/rules/data/force-dynamic-layout.md)

`dynamic = "force-dynamic"`, `revalidate = 0` or `fetchCache = "force-no-store"` in a layout opts every page below it out of static rendering and the data cache.

Fix: Move the segment config to the pages that need per-request rendering, or read cookies()/headers() only where needed.

### data/uncached-fetch

**Constant fetch uncached on a dynamic route.** Severity: warning. Page: [`data/uncached-fetch`](http://localhost:4173/rules/data/uncached-fetch.md)

Since Next.js 15 fetch is not cached by default: on a route rendered per request, a fetch of a constant URL refetches the same data on every request.

Fix: Add `next: { revalidate: N }` (or `tags`), `cache: "force-cache"`, or move the call into a "use cache" function.

### data/serial-route-fetch

**Same query in generateMetadata and the page.** Severity: warning. Page: [`data/serial-route-fetch`](http://localhost:4173/rules/data/serial-route-fetch.md)

fetch is deduped per request, but other data calls are not: generateMetadata and the page each run the query, doubling the database work.

Fix: Wrap the loader in React cache() (`export const getPost = cache(async (slug) => …)`) and call it from both.

## SEO

### seo/meta-description

**No meta description.** Severity: warning. Page: [`seo/meta-description`](http://localhost:4173/rules/seo/meta-description.md)

Lighthouse SEO fails pages without <meta name="description">, and search results fall back to a snippet scraped from the page.

Fix: Add `description` to the root layout's metadata, or to the page's own metadata/generateMetadata.

### seo/link-text

**Non-descriptive link text.** Severity: warning. Page: [`seo/link-text`](http://localhost:4173/rules/seo/link-text.md)

Links reading "click here", "learn more" or "read more" tell search engines and screen-reader link lists nothing about the target (Lighthouse: links do not have descriptive text).

Fix: Write link text that names the destination, or give the link an aria-label.

### seo/crawlable-link

**Link crawlers can't follow.** Severity: warning. Page: [`seo/crawlable-link`](http://localhost:4173/rules/seo/crawlable-link.md)

An <a> with a javascript: href, or one that navigates in onClick with no real href, has no URL for crawlers (Lighthouse: links are not crawlable).

Fix: Put the destination in href (use <Link href>), or use a <button> for actions.

### seo/noindex

**Root layout blocks indexing.** Severity: warning. Page: [`seo/noindex`](http://localhost:4173/rules/seo/noindex.md)

robots noindex in the root layout's metadata removes every page from search results (Lighthouse: page is blocked from indexing).

Fix: Remove it, or set it only outside production, e.g. from VERCEL_ENV.

## Accessibility

### a11y/link-name

**Link without a name.** Severity: error. Page: [`a11y/link-name`](http://localhost:4173/rules/a11y/link-name.md)

A link with only an icon, an image with empty alt, or nothing inside is announced as just "link" (Lighthouse: links do not have a discernible name).

Fix: Add aria-label, or visible or sr-only text inside the link.

### a11y/button-name

**Empty button.** Severity: error. Page: [`a11y/button-name`](http://localhost:4173/rules/a11y/button-name.md)

A <button> with no content and no label is announced as just "button" (Lighthouse: buttons do not have an accessible name).

Fix: Give it text or an aria-label.

### a11y/form-label

**Form field without a label.** Severity: error. Page: [`a11y/form-label`](http://localhost:4173/rules/a11y/form-label.md)

Screen readers announce an unnamed input, select or textarea (Lighthouse: form elements do not have associated labels).

Fix: Wrap it in <label>, pair an id with <label htmlFor>, or add aria-label.

### a11y/tabindex-positive

**Positive tabIndex.** Severity: warning. Page: [`a11y/tabindex-positive`](http://localhost:4173/rules/a11y/tabindex-positive.md)

A tabIndex above 0 moves the element ahead of the natural tab order, which confuses keyboard users (Lighthouse: some elements have a tabindex value greater than 0).

Fix: Use tabIndex 0 or -1, and order the DOM instead.

### a11y/aria-hidden-focus

**Focusable element under aria-hidden.** Severity: error. Page: [`a11y/aria-hidden-focus`](http://localhost:4173/rules/a11y/aria-hidden-focus.md)

Keyboard focus reaches an element screen readers can't see (Lighthouse: [aria-hidden] elements contain focusable descendants).

Fix: Add tabIndex={-1} to the focusable element, or remove aria-hidden.

### a11y/iframe-title

**Iframe without a title.** Severity: error. Page: [`a11y/iframe-title`](http://localhost:4173/rules/a11y/iframe-title.md)

Screen readers announce an unnamed frame (Lighthouse: <frame> or <iframe> elements do not have a title).

Fix: Add a title that describes the frame's content.

### a11y/heading-skip

**Skipped heading level.** Severity: warning. Page: [`a11y/heading-skip`](http://localhost:4173/rules/a11y/heading-skip.md)

Jumping from h1 to h3 breaks the outline that screen-reader users navigate by (Lighthouse: heading elements are not in a sequentially-descending order).

Fix: Use the next level down and style it with classes.

### a11y/dup-id

**Duplicate id.** Severity: warning. Page: [`a11y/dup-id`](http://localhost:4173/rules/a11y/dup-id.md)

With the same id twice in one render, labels, aria references and focus land on the first element only.

Fix: Make each id unique, e.g. with useId().

## Best practices

### bp/document-write

**document.write().** Severity: warning. Page: [`bp/document-write`](http://localhost:4173/rules/bp/document-write.md)

document.write blocks parsing, and Chrome skips it on slow connections (Lighthouse: uses document.write()).

Fix: Insert elements with DOM APIs, or load scripts with next/script.

### bp/unload-listener

**unload listener.** Severity: warning. Page: [`bp/unload-listener`](http://localhost:4173/rules/bp/unload-listener.md)

An unload handler keeps the page out of the back/forward cache and often never fires on mobile (Lighthouse: page prevented back/forward cache restoration).

Fix: Listen for pagehide or visibilitychange instead.

### bp/passive-listener

**Non-passive scroll listener.** Severity: warning. Page: [`bp/passive-listener`](http://localhost:4173/rules/bp/passive-listener.md)

The browser waits for a touch or wheel listener before every scroll frame unless it is passive (Lighthouse: does not use passive listeners to improve scrolling performance).

Fix: Pass { passive: true } when the handler never calls preventDefault().

### bp/http-url

**Plain-HTTP resource.** Severity: warning. Page: [`bp/http-url`](http://localhost:4173/rules/bp/http-url.md)

Loading a subresource or browser fetch over http:// from an HTTPS page is mixed content, which the browser blocks or flags (Lighthouse: does not use HTTPS).

Fix: Use an https:// URL.
