Actionable guidelines for building fast, accessible, and maintainable web apps with today’s toolchains.
Modern web development isn’t about chasing every new framework. It’s about shipping fast, accessible experiences with a workflow that scales. In this post, I’ll share the practices I use across projects to improve performance, accessibility, and developer experience (DX). Each section includes code and concrete steps you can adopt today.
Your first render should be fast and meaningful. Treat the critical path—HTML, CSS, minimal JS, and above‑the‑fold assets—as sacred.
<!-- Preload critical font and hero image -->
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/img/hero.webp" as="image" imagesrcset="/img/hero.webp 1x, /img/hero@2x.webp 2x">defer or type="module"Web Vitals are north stars: LCP, INP, CLS.
// web-vitals.ts
import { onLCP, onINP, onCLS } from 'web-vitals';
import { send } from './analytics';
onLCP((m) => send('lcp', m.value));
onINP((m) => send('inp', m.value));
onCLS((m) => send('cls', m.value));Track these per route and surface regressions in CI and dashboards.
Hydration tax is real. Consider hybrid rendering with Next.js/Nuxt and React Server Components or Vue SSR to move work to the server.
// Next.js (App Router) server component example
// app/products/page.tsx
import { getProducts } from '@/lib/db';
export default async function ProductsPage() {
const products = await getProducts(); // runs on server
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}use server actions to reduce API round‑tripsAvoid expensive re‑work with layered caches.
# Static assets (immutable)
Cache-Control: public, max-age=31536000, immutable
# HTML (short‑lived)
Cache-Control: public, s-maxage=60, stale-while-revalidate=300Accessibility is table stakes, not an afterthought. Bake it in from the first component.
Use the correct elements and regions so assistive tech can navigate your UI.
<header role="banner">...</header>
<nav aria-label="Primary">...</nav>
<main id="content" role="main">...</main>
<footer role="contentinfo">...</footer>Every input needs a visible label and an accessible name. Associate errors with their fields.
<label for="email">Email</label>
<input id="email" name="email" type="email" aria-describedby="email-hint email-error">
<div id="email-hint" class="hint">We’ll never share your email.</div>
<div id="email-error" class="error" role="alert" hidden>Enter a valid email.</div>Make interactive elements focusable and visibly focused. Manage focus on route transitions and dialogs.
// After rendering a modal
modalEl.showModal();
modalEl.querySelector<HTMLElement>('[data-initial]')?.focus();
// Restore focus when closing
const lastFocus = document.activeElement as HTMLElement;
modalEl.addEventListener('close', () => lastFocus?.focus());prefers-reduced-motion alternatives@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
}Add checks to CI so regressions fail fast.
# Example CI step
npx axe --chromium --urls https://localhost:3000 --disable-iframe
npx lighthouse http://localhost:3000 --preset=desktop --output=json --output-path=./reports/lh.jsonGreat DX isn’t just nice to have. It shortens lead time, reduces defects, and improves morale.
// package.json scripts
{
"scripts": {
"dev": "next dev",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint .",
"test": "vitest",
"prepush": "npm run typecheck && npm run lint && npm run test -w"
}
}Codify decisions once and reuse everywhere.
// .eslintrc.json
{
"extends": ["next", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended"],
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"react-hooks/exhaustive-deps": "warn"
}
}src/
app/
components/
features/
checkout/
components/
hooks/
api/Use a single source of truth for schemas and generate types for client and server.
// zod schema → types and runtime validation
import { z } from 'zod';
export const Order = z.object({ id: z.string().cuid(), total: z.number().nonnegative() });
export type Order = z.infer<typeof Order>;Every PR should have a deploy preview with automated checks.
# .github/workflows/ci.yml
name: ci
on: [pull_request]
jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run typecheck && npm run lint && npm test
- run: npm run build
- name: Upload build
uses: actions/upload-artifact@v4
with: { name: web, path: .next }Wire this to your hosting provider (Vercel/Netlify/Fly) for ephemeral previews.
Performance, accessibility, and DX reinforce each other. Fast, inclusive apps with reliable tooling are easier to maintain and a better experience for everyone—users, teammates, and on‑call engineers. Pick one improvement from each section, ship it this week, and measure the impact. Momentum follows.