Across the stack12 min read
Web best practices
Performance, accessibility, security, testing, SEO and code review: the common foundation of every modern web application we ship.
Performance
Google measures real experience with three Core Web Vitals. We aim for green on mobile, at our users' 75th percentile. Move the sliders to see thresholds and fixes.
Needs improvement
Time to render the largest visible element (hero image, heading).
Render on the server, add priority to the hero image, set sizes, avoid blocking fonts.
Good
Delay between an interaction (click, keypress) and the visible screen update.
Needs improvement
Sum of unexpected layout shifts during the visit.
Dimensions on images, correctly sized skeletons, next/font, no banner injected above content.
- Server Components by default: the fastest JavaScript is the JavaScript you don't send.
- next/image for every image: modern formats, right sizes, lazy loading.
- next/font for fonts: hosted with the app, no layout shift.
- Dynamic imports for heavy libraries (charts, editors) used below the fold.
- Measure before optimizing:
next experimental-analyzefor the bundle, Vercel Speed Insights for real visits.
import Image from 'next/image';
<Image
src={product.imageUrl}
alt={product.name} // describes the image, never "image"
width={640}
height={480}
sizes="(min-width: 1024px) 33vw, 100vw"
priority={index === 0} // only the LCP image above the fold
/>import { Inter } from 'next/font/google';
// Self-hosted at build time: no layout shift, no request to Google
const inter = Inter({ subsets: ['latin'], display: 'swap', variable: '--font-sans' });import dynamic from 'next/dynamic';
// The chart library (~200 kB) only loads when the component renders
const RevenueChart = dynamic(() => import('./revenue-chart'), {
loading: () => <ChartSkeleton />,
});Accessibility
Target: WCAG 2.2 level AA. It's also a legal requirement for many services in Europe since June 2025 (European Accessibility Act).
- Semantic HTML first:
buttonfor actions,afor navigation, headings in order. - Every field has a visible label, and every error is linked to its field with
aria-describedby. - Everything by keyboard: Tab through it, focus always visible, no traps in dialogs.
- Contrast of at least 4.5:1 for text, and information never relies on color alone.
- Changing content (loading, toasts) announced with
aria-liveorrole="status". - Reduced motion respected: animations disabled with
prefers-reduced-motion.
Security
A Server Action is a public HTTP endpoint: anyone can call it with any arguments. It validates its input and lets Laravel check permissions.
Avoid
'use server';
// Anyone can call this endpoint with any id
export async function deleteInvoice(id: number) {
await db.invoice.delete({ where: { id } });
}Do
'use server';
export async function deleteInvoice(input: unknown) {
const { id } = deleteInvoiceSchema.parse(input); // validate
// Laravel checks the session token AND the InvoicePolicy
await apiFetch(`/v1/invoices/${id}`, { method: 'DELETE' });
updateTag('invoices');
}- No secrets client-side: no API key in a Client Component or in a
NEXT_PUBLIC_variable. - server-only imported in every file that reads cookies, secrets or calls the API.
- No dangerouslySetInnerHTML with user content; otherwise sanitize with DOMPurify.
- Security headers configured in
next.config.ts, CSP on publicly exposed projects. - Up-to-date dependencies: Dependabot on,
npm auditandcomposer auditin CI.
// next.config.ts
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
}npm audit --omit=dev # front-end dependencies
composer audit # Laravel dependencies
npx next upgrade # stay on the latest patch: security fixes ship thereFront-end testing
Vitest and Testing Library for components (test what users see, by role and text), Playwright for full journeys and automated accessibility with axe, in both languages.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NextIntlClientProvider } from 'next-intl';
import { describe, expect, it } from 'vitest';
import messages from '../../../../messages/fr/invoices.json';
import { InvoiceFilters } from './invoice-filters';
const renderFr = (ui: React.ReactElement) =>
render(<NextIntlClientProvider locale="fr" messages={{ invoices: messages }}>{ui}</NextIntlClientProvider>);
describe('InvoiceFilters', () => {
it('marks the selected status as pressed', async () => {
renderFr(<InvoiceFilters />);
await userEvent.click(screen.getByRole('button', { name: 'Payée' }));
expect(screen.getByRole('button', { name: 'Payée' })).toHaveAttribute('aria-pressed', 'true');
});
});import AxeBuilder from '@axe-core/playwright';
import { expect, test } from '@playwright/test';
for (const locale of ['fr', 'en']) {
test(`invoices page is accessible in ${locale}`, async ({ page }) => {
await page.goto(`/${locale}/invoices`);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
const { violations } = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
expect(violations).toEqual([]);
});
}SEO and metadata
For public pages: generateMetadata translated per language, hreflang tags through alternates, sitemap.ts and robots.ts. Internal apps add robots: noindex.
import type { MetadataRoute } from 'next';
// app/sitemap.ts: one entry per page, with its translations
export default function sitemap(): MetadataRoute.Sitemap {
return ['', '/pricing', '/contact'].map((path) => ({
url: `https://app.example.com/fr${path}`,
alternates: {
languages: {
fr: `https://app.example.com/fr${path}`,
en: `https://app.example.com/en${path}`,
},
},
}));
}Code review
When you open a PR
- Review your own PR in the GitHub UI before assigning anyone: half the comments disappear.
- Explain the why in the description, not just the what. Add FR and EN screenshots when the UI changes.
- Reply to every comment, even with "fixed in abc123".
When you review
- Comment on the code, not the person, and suggest a fix when you raise a problem.
- Prefix the weight:
blocking:,suggestion:,question:. - Reply within one business day: a waiting PR blocks a teammate.