Frontend / Checklist
Get the document right before anything renders
Thirty HTML rules covering the head, the document skeleton, semantics, forms, media, navigation, components, script loading, the web manifest and the internationalization basics that are far cheaper to build in than to bolt on. Each rule carries a priority so you can tell a broken charset, which nothing else on the page survives, from a missing poster frame, which is nice to have and nothing more.
30 rules in 10 groups. Adapted from the Front-End Checklist (David Dias, MIT), rewritten and re-verified September 2026.
What this checklist decides
HTML mistakes are the cheapest to make and the most expensive to find, because nothing crashes. A missing doctype drops the page into quirks mode, a late charset turns every apostrophe into three bytes of garbage, and a form without labels passes every visual test while locking out screen reader users. The browser papers over all of it, which is why these rules need a checklist and not just a code review.
Work top to bottom inside each group. Critical rules are the ones a build should refuse to ship without, High rules get fixed before launch, Medium rules go on the next sprint, and Low rules are worth doing whenever they cost less than an hour. The head-of-document rules come first because everything downstream - CSS, scripts, SEO, accessibility - assumes they are right.
- Critical blocks the ship
- High fix before launch
- Medium fix this quarter
- Low worth it when cheap
Meta
Four tags in the head that every other rule on this site assumes are present, correct, and in the right order.
| Rule | Priority | What to do |
|---|---|---|
| Declare UTF-8 before anything else in head | Critical | <meta charset="utf-8"> is the first child of <head>, ahead of the title. The spec only guarantees it is honored inside the first 1024 bytes, and anything the parser decoded before reaching it may be thrown away and re-parsed. |
| Set a responsive viewport | Critical | <meta name="viewport" content="width=device-width, initial-scale=1"> and nothing else. Without it phones render a desktop-width page and scale it down; with maximum-scale or user-scalable=no added, you have failed an accessibility audit. |
| Declare the page language | High | <html lang="en"> with a BCP 47 tag (en-GB, pt-BR). Screen readers choose their voice from it, browsers pick hyphenation and quote glyphs from it, and translation prompts depend on it. Mark inline passages in another language with their own lang. |
| Ship favicons for every surface | Medium | An SVG icon via <link rel="icon" href="/favicon.svg" type="image/svg+xml">, a favicon.ico at the site root for anything that ignores the link, a 180px <link rel="apple-touch-icon"> PNG, and 192px plus 512px PNGs in the manifest for home screens and the install prompt. |
The first five lines of every page, in this order. Everything else in head comes after the title:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pricing - Acme</title> Gotcha: a layout template that hard-codes lang="en" while serving translated pages is worse than no attribute at all. Screen readers take the voice from the attribute, not from the text, so a French page announced in an English voice is unintelligible. Make lang a template variable the day you add a second language.
Document structure
The skeleton the browser builds before a single stylesheet arrives. Get it wrong and every layer above it inherits the fault.
| Rule | Priority | What to do |
|---|---|---|
| Start with the HTML5 doctype | Critical | <!doctype html> as the very first bytes of the file, no whitespace or byte-order mark ahead of it. Omit it and the browser enters quirks mode, where the box model, table font inheritance and percentage heights all behave like it is 2001. |
| Keep every id unique | High | A duplicated id silently breaks label for, aria-labelledby, aria-describedby, fragment links and getElementById - only the first match wins and nothing warns you. Lint with html-validate's no-dup-id or a build-time scan, and prefix ids by component when several instances can share a page. |
| Serve a real 404 page with a real 404 status | Medium | A branded page with the site navigation, a search or the top links, and the same layout as everything else - and an HTTP 404, not a 200. On static hosts (Cloudflare Pages, Netlify, GitHub Pages) a 404.html at the output root does both. |
| Set the text direction for right-to-left scripts | Medium | dir="rtl" on <html> for Arabic, Hebrew, Persian and Urdu pages, dir="auto" on inputs and any element that shows user-written text, and logical CSS properties (margin-inline-start) so the layout mirrors on its own. |
Gotcha: a single-page app whose host rewrites every unknown path to the app shell returns 200 for garbage URLs. Search engines record those as soft 404s and keep crawling them. Make the router render a not-found view and, where the host allows it, return the status too.
Semantics and best practices
The right element gives assistive technology, search engines and your own future self information a div never will.
| Rule | Priority | What to do |
|---|---|---|
| Reach for the semantic element first | High | One <main>, landmarks from <header>, <nav>, <aside> and <footer>, <button> for actions and <a href> for navigation, real lists, real tables, one heading level at a time. Each of these arrives with keyboard behavior and a role that a <div> needs a dozen lines of ARIA and script to fake. |
| Validate the output, not the template | High | Run the built pages through the W3C Nu HTML Checker or html-validate in CI. It catches unclosed elements, block elements inside inline ones, nested interactive controls and duplicate attributes - the errors browsers auto-correct differently from each other. |
| Strip comments and debug markup from production | Medium | Let the minifier remove comments (html-minifier-terser with removeComments), and make sure TODO notes, commented-out blocks, test-only data-* hooks and staging banners never reach the output. Comments are shipped to every visitor and read by every competitor. |
Gotcha: validation and semantics are different checks. <div role="button" tabindex="0"> validates cleanly and still fails keyboard users, because Enter and Space do nothing on it until you write the handler that <button> gets for free. Pass the validator, then read the element list.
Forms
Forms are where HTML does the most work for you, and where the fewest projects let it.
| Rule | Priority | What to do |
|---|---|---|
| Report validation errors to assistive tech | High | Set aria-invalid="true" on the failing control, put the message in an element the control references through aria-describedby, move focus to the first error on submit, and never disable the submit button as a substitute. Color alone is not an error state. |
| Use the input type, inputmode and autocomplete that match the data | High | type="email", tel, url and date give phones the right keyboard and browsers free validation; autocomplete tokens (given-name, postal-code, one-time-code) let password managers and autofill do the typing. inputmode="numeric" where a number is typed but type="number" is wrong. |
| Keep file inputs reachable and described | Medium | A visible <label> tied to the <input type="file">, an accept list and the size limit stated next to it, and progress announced through an aria-live region. A styled drop zone must still let the keyboard reach the native input underneath - hide it visually, never with display: none. |
| Mark search as search | Medium | Wrap it in <search> (or <form role="search"> for older browsers), use type="search", and give the field a label even if it is visually hidden. Screen reader users jump straight to the search landmark; a bare text input in a div is invisible to that shortcut. |
One field, everything wired: the right type, the autofill token, the phone keyboard, and an error the screen reader reads with the field.
<label for="email">Email</label>
<input id="email" name="email" type="email"
autocomplete="email" inputmode="email" required
aria-invalid="true" aria-describedby="email-error">
<p id="email-error">Enter an address with an @ in it.</p> Gotcha: type="number" is for quantities, not digits. It strips leading zeros, rejects the plus sign in a phone number, changes value on scroll wheel in some browsers, and shows a spinner nobody asked for. Postcodes, card numbers and one-time codes are type="text" inputmode="numeric" with a pattern.
Media
Two rules for video; images have a checklist of their own.
| Rule | Priority | What to do |
|---|---|---|
| Caption every video and let it be paused | High | A WebVTT file through <track kind="captions" srclang="en" label="English" default>, a transcript on the page for anything with speech, the controls attribute so it can be paused and scrubbed, and no autoplay with sound. Auto-generated captions are a starting draft, not the deliverable. |
| Give videos a poster frame | Medium | poster="/video/intro-poster.png" so the player shows a real frame instead of a black box, paired with preload="metadata" (or none) so the page does not pull the whole file for a video most visitors never play. Size the poster like an image: width, height, compressed. |
Gotcha: a captions file served from a different origin than the page is silently ignored unless the <video> carries crossorigin="anonymous" and the CDN sends CORS headers for the .vtt. The player looks fine; the captions button just never appears.
Navigation
Both patterns are a labelled <nav> around a list of links with the current item marked - the details are what get skipped.
| Rule | Priority | What to do |
|---|---|---|
| Build breadcrumbs as a labelled list | Medium | <nav aria-label="Breadcrumb"> wrapping an <ol>, every ancestor a link, the current page as plain text with aria-current="page", and separators drawn in CSS. Mirror it in BreadcrumbList JSON-LD so search results show the same trail. |
| Make pagination readable and reachable | Medium | <nav aria-label="Pagination"> around a list of real links, aria-current="page" on the active number, the words Previous and Next in the link (visually hidden if the design wants chevrons), and disabled ends rendered as text rather than dead links. Every page needs its own crawlable URL. |
Gotcha: a literal / or > typed between breadcrumb links is read aloud on every item. Put separators in li + li::before and they exist for sighted users only, which is the whole point of them.
Components
Custom elements and the no-script path are both about what happens when the browser is not the one you tested in.
| Rule | Priority | What to do |
|---|---|---|
| Give custom elements real semantics | Medium | Call this.attachInternals() and set role and ARIA states through ElementInternals; set static formAssociated = true so a custom input submits with its form and takes part in validation; open the shadow root with delegatesFocus: true; handle the keyboard the native element would. Or wrap a native element and style it - usually the better answer. |
| Say something useful when scripts are off | Medium | Render the content on the server so the page works without JavaScript by default, then a <noscript> block that names what will not work (live search, the checkout) and links to the fallback. A blank white page with a spinner inside <noscript> helps nobody. |
Gotcha: <noscript> only renders when scripting is disabled. It does nothing when your bundle 404s, is blocked by a corporate proxy, or throws on line one - which is how most visitors actually end up without JavaScript. Server-rendered content is the only fallback that covers those cases.
Performance and security
Two attributes on the script tag decide whether the parser waits and whether a compromised CDN can run code on your page.
| Rule | Priority | What to do |
|---|---|---|
| Never ship a parser-blocking script | High | type="module" for your own code (deferred by definition), defer for classic scripts that need the DOM and each other in order, async only for independent third parties such as analytics. A bare <script src> in head halts parsing until it has downloaded and run. |
| Pin third-party scripts with Subresource Integrity | High | For any script or stylesheet loaded from a CDN you do not control, add integrity="sha384-..." and crossorigin="anonymous" so a swapped file fails to load instead of running. This only applies to third-party CDN files - self-hosting the library removes the need entirely, which is why this site's own rule is no library CDNs at all. Generate the hash with openssl dgst -sha384 -binary file | openssl base64 -A. |
Gotcha: async scripts execute in the order they finish downloading, not the order they appear, so two async scripts with a dependency between them race - and win or lose depending on the network. Use defer, which preserves document order, or a module graph where the dependency is an import.
Setup
The manifest is cheap and makes the site installable; whether you want it installable is a product decision, so the second rule stays Low.
| Rule | Priority | What to do |
|---|---|---|
| Link a web app manifest | Medium | <link rel="manifest" href="/site.webmanifest"> pointing at JSON with name, short_name, start_url, display, theme_color, background_color and 192px plus 512px icons, one of them purpose: "maskable". Pair it with <meta name="theme-color"> for the browser chrome. |
| Meet the install criteria on purpose | Low | HTTPS, the manifest above with display set to standalone or minimal-ui, and a scope that covers every URL the installed app should stay inside. Add a service worker only if you actually want offline behavior; Chromium no longer requires one for the install prompt. Check the Application panel in DevTools, which lists exactly which criterion is unmet. |
Gotcha: start_url and scope are enforced. An installed app that starts at /app/ with a scope of /app/ opens /blog/ in a browser tab with a URL bar, which looks like a bug to the user. Set scope to / unless you mean otherwise.
Internationalization
Five rules that cost almost nothing on the first day and a rewrite on the day marketing asks for German.
| Rule | Priority | What to do |
|---|---|---|
| Format numbers, money and dates with Intl | Medium | Intl.NumberFormat with style: "currency", Intl.DateTimeFormat with dateStyle, Intl.RelativeTimeFormat for "3 days ago", Intl.ListFormat for "a, b and c". Never toFixed(2) plus a hard-coded symbol - decimal separators, grouping and symbol position all change by locale. |
| Pluralize with PluralRules or ICU messages | Medium | English has two plural forms; Arabic has six and most Slavic languages more than two, so count === 1 ? "item" : "items" is wrong the moment you translate. Intl.PluralRules returns the category (one, few, many, other) and an ICU MessageFormat library (FormatJS, i18next) keeps the variants in the translation file where translators can see them. |
| Add reciprocal hreflang links on multilingual sites | Medium | One <link rel="alternate" hreflang="de" href="..."> per language version on every page, including a self-referencing one and an x-default. Every page in the set must list every other page - a one-way link is ignored. Skip all of it on a single-language site. |
| Leave room for text that grows | Medium | German, Finnish and French translations routinely run 30 to 50 percent longer than the English. No fixed widths on buttons, tabs, labels or table headers; min-width instead of width, wrapping allowed, no text baked into images. Test with pseudo-localization before the real translations arrive. |
| Keep images locale-neutral and swap the exceptions | Low | No words inside images, no hand gestures or flags standing in for languages, no screenshots of an English UI on a German page. Where an image must differ by locale, choose it in the template from the page's lang or with a :lang() selector, and keep the alt text translated with the rest of the copy. |
Both APIs are built into every browser. Read the locale once from the document and pass it everywhere:
const locale = document.documentElement.lang || 'en-US';
const money = new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' });
money.format(1234.5); // en-US: "$1,234.50" de-DE: "1.234,50 $"
const plural = new Intl.PluralRules(locale);
const forms = { one: '{n} item', other: '{n} items' };
const label = (n) => forms[plural.select(n)].replace('{n}', n);
label(1); // "1 item"
label(5); // "5 items"
new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date()); Gotcha: toLocaleString() with no argument formats for whatever locale the visitor's browser reports, which is often not the language of the page they are reading. Pass the page locale explicitly, from one constant, or a German page shows American dates to a German user on an English-language laptop.
Keep going
The Critical and High rows above are folded into the launch checklist, which is the one to run the day before a deploy. The CSS checklist picks up where the head leaves off: how the stylesheet this page links to gets loaded without blocking the first paint.
Most of the meta rules here have a search-engine twin - canonical, robots, Open Graph, structured data - in the technical SEO checklist. The forms, navigation and semantics rules are the HTML half of the accessibility checklist, and the accessibility cheatsheet has the ARIA patterns for the components a native element cannot cover.
Building a content site? The static site stack is the setup where every rule on this page is a build-time check rather than a runtime one.