Build on semantic HTML before reaching for JavaScript
Use the correct native element for each job - button, a, nav, main, label, headings, lists - because semantic markup brings keyboard support, screen-reader semantics, and resilience for free, following the principle of progressive enhancement. Layer CSS and JavaScript on top to enhance, not to replace, a working baseline.
- Do: use a real
- Don't: attach a click handler to a
and reinvent focus, keyboard, and role handling badly.
Always set dimensions on images and media
Give every image, video, and embed an explicit width and height or an aspect-ratio so the browser reserves the correct box before the asset loads, preventing Cumulative Layout Shift. Reserving space keeps surrounding content from jumping when media arrives.
- Do:
so the layout is stable from first paint.
- Don't: ship an unsized
that pushes content down when it loads, spiking CLS above the 0.1 threshold.
Optimise the Largest Contentful Paint element deliberately
Identify the element that is your LCP - usually a hero image or headline - and prioritise its delivery by preloading it, serving it from a CDN, using modern formats, and removing render-blocking resources ahead of it. A good LCP is 2.5 seconds or less, and it is the metric users feel as "the page loaded".
- Do:
for the hero image. - Don't: lazy-load the LCP image, which delays the very paint the metric measures.
Ship less JavaScript with code splitting and tree shaking
Keep the initial bundle small by splitting code per route and lazy-loading heavy features with dynamic import(), and by importing only what you use from side-effect-free ES modules so the bundler can tree-shake the rest. Less JavaScript means faster Time To Interactive and better INP.
- Do: lazy-load a charting library only on the analytics route that needs it.
- Don't: import an entire utility library to use one function, dragging the whole thing into the bundle.
Make the UI keyboard-operable with visible focus
Ensure every interactive control can be reached and activated with the keyboard alone, in a logical tab order, and never remove the focus outline without replacing it with an equally visible one. Keyboard access is a baseline WCAG requirement and the foundation for many assistive technologies.
- Do: style
:focus-visiblewith a clear outline so keyboard users always see where they are. - Don't: set
outline: noneglobally, leaving keyboard users unable to track focus.
Prefer native HTML elements over ARIA
Follow the first rule of ARIA: if a native HTML element provides the semantics and behaviour you need, use it instead of recreating them with ARIA roles and JavaScript. Incorrect ARIA actively misleads assistive technology and is worse than no ARIA at all.
- Do: use
- Don't: build
when a real element would do the job correctly.
Treat all user input as untrusted and avoid unsafe HTML injection
Rely on your framework's automatic output encoding when rendering data, and never pass unsanitised user content to innerHTML or dangerouslySetInnerHTML. If you must render HTML, sanitise it with a vetted library, because XSS is one of the most common front-end vulnerabilities.
- Do: render user text as text via interpolation, or sanitise with DOMPurify when HTML is required.
- Don't: build markup with string concatenation of user input and assign it to
element.innerHTML.
Deploy a strict Content Security Policy as defence-in-depth
Add a Content Security Policy header that restricts script and resource origins, ideally nonce-based, so that even if an injection slips through, the browser refuses to execute unauthorised scripts. Pair it with Subresource Integrity for third-party scripts to catch tampered CDNs.
- Do: serve
Content-Security-Policy: script-src 'self' 'nonce-...'and addintegrityhashes to external scripts. - Don't: rely on framework escaping alone and allow
script-src 'unsafe-inline', which defeats the policy.
Design responsively, mobile-first
Author your base styles for the smallest screen and use min-width media (or container) queries to enhance layouts upward, so the simplest experience is the default and complexity is added only where there is room. A single fluid codebase serves the unpredictable range of real devices better than fixed per-device layouts.
- Do: write base layout for narrow screens, then
@media (min-width: 48rem)to add columns. - Don't: design desktop-first and bolt on
max-widthoverrides, fighting the cascade for mobile.
Limit high-frequency event work with debouncing or throttling
Wrap handlers for scroll, resize, and keystroke events so they do not run on every single event, using debouncing when you want the trailing call after activity stops and throttling when you want a steady, capped cadence. This keeps the main thread free and protects responsiveness and INP.
- Do: debounce a search-as-you-type request so it fires once the user pauses typing.
- Don't: issue a network request on every keystroke, flooding the server and the UI thread.
Choose the rendering strategy per route, not per app
Match each page to the right rendering mode: static generation (SSG) for content that rarely changes, server rendering (SSR) for personalised or SEO-critical dynamic pages, and client rendering for highly interactive app shells. Frameworks let you mix these, so optimise each route instead of forcing one strategy everywhere.
- Do: pre-render marketing pages with SSG and server-render the logged-in dashboard with SSR.
- Don't: client-render an SEO-critical landing page, leaving crawlers and first-paint users with an empty shell.
Set and enforce a performance budget in CI
Define limits for bundle size and key metrics (LCP, INP, CLS, total JavaScript) and check them automatically on every change so regressions are caught before release, not in production. A budget turns performance from an afterthought into a gate.
- Do: fail the pipeline when the main bundle exceeds the budget or Lighthouse scores drop.
- Don't: only test performance manually before launch, then watch it silently erode with each feature.
Keep state as local as possible
Hold state at the lowest level that needs it and lift or centralise only what must be shared, distinguishing server state (fetched data needing caching and invalidation) from client UI state. Over-centralising everything in a global store early adds boilerplate, coupling, and re-render churn.
- Do: use a server-state library (e.g. TanStack Query) for API data and component state for local UI.
- Don't: dump every transient toggle and form field into a single global store "just in case".
Provide accessible names and labels for every control
Make sure each interactive element exposes a clear accessible name - via a visible , aria-label, or aria-labelledby - so screen-reader users know what it does, and associate form fields with their labels. Icon-only buttons especially need an accessible name.
- Do:
and. - Don't: ship an icon-only button or an unlabelled input that announces as "button" or "edit text" with no purpose.