Transverse12 min de lecture
Bonnes pratiques web
Performance, accessibilité, sécurité, tests, SEO et revue de code : le socle commun à toute application web moderne que l'on livre.
Performance
Google mesure l'expérience réelle avec trois Core Web Vitals. On vise le vert sur mobile, au 75e centile de nos utilisateurs. Déplace les curseurs pour voir les seuils et les corrections.
À améliorer
Temps d'affichage du plus grand élément visible (image principale, titre).
Rends la page côté serveur, ajoute priority sur l'image principale, précise sizes, évite les polices bloquantes.
Bon
Délai entre une interaction (clic, frappe) et la mise à jour visible de l'écran.
À améliorer
Somme des sauts de mise en page inattendus pendant la visite.
Dimensions sur les images, skeletons à la bonne taille, next/font, pas de bannière injectée au-dessus du contenu.
- Server Components par défaut : le JavaScript le plus rapide est celui qu'on n'envoie pas.
- next/image pour toute image : formats modernes, tailles adaptées, chargement différé.
- next/font pour les polices : hébergées avec l'app, sans saut de mise en page.
- Import dynamique des bibliothèques lourdes (graphiques, éditeurs) utilisées sous la ligne de flottaison.
- Mesurer avant d'optimiser :
next experimental-analyzepour le bundle, Vercel Speed Insights pour les vraies visites.
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 />,
});Accessibilité
Cible : WCAG 2.2 niveau AA. C'est aussi une obligation légale pour de nombreux services en Europe depuis juin 2025 (European Accessibility Act).
- HTML sémantique d'abord :
buttonpour une action,apour une navigation, titres dans l'ordre. - Chaque champ a un label visible, et chaque erreur est reliée au champ avec
aria-describedby. - Tout au clavier : parcours avec Tab, focus toujours visible, pas de piège dans les modales.
- Contraste de 4,5:1 minimum pour le texte, et l'information ne passe jamais par la couleur seule.
- Contenu qui change (chargement, toasts) annoncé avec
aria-liveourole="status". - Mouvement réduit respecté : animations coupées avec
prefers-reduced-motion.
Sécurité
Une Server Action est un endpoint HTTP public : n'importe qui peut l'appeler avec n'importe quels arguments. Elle valide ses entrées et laisse Laravel vérifier les droits.
À éviter
'use server';
// Anyone can call this endpoint with any id
export async function deleteInvoice(id: number) {
await db.invoice.delete({ where: { id } });
}À faire
'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');
}- Jamais de secret côté client : pas de clé d'API dans un Client Component ni dans une variable
NEXT_PUBLIC_. - server-only importé dans tout fichier qui lit des cookies, des secrets ou appelle l'API.
- Pas de dangerouslySetInnerHTML avec du contenu venant d'un utilisateur ; sinon, nettoyage avec DOMPurify.
- En-têtes de sécurité configurés dans
next.config.ts, CSP sur les projets exposés publiquement. - Dépendances à jour : Dependabot activé,
npm auditetcomposer auditdans la 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 thereTests front
Vitest et Testing Library pour les composants (on teste ce que voit l'utilisateur, par rôle et par texte), Playwright pour les parcours complets et l'accessibilité automatisée avec axe, dans les deux langues.
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 et métadonnées
Pour les pages publiques : generateMetadata traduit par langue, balises hreflang via alternates, sitemap.ts et robots.ts. Les applications internes ajoutent 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}`,
},
},
}));
}Revue de code
Quand tu ouvres une PR
- Relis ta propre PR dans l'interface GitHub avant d'assigner quelqu'un : la moitié des remarques disparaissent.
- Explique le pourquoi dans la description, pas seulement le quoi. Ajoute des captures FR et EN si l'interface change.
- Réponds à chaque commentaire, même par « corrigé dans abc123 ».
Quand tu relis
- Commente le code, pas la personne, et propose une solution quand tu signales un problème.
- Préfixe l'importance :
bloquant:,suggestion:,question:. - Réponds sous 24 heures ouvrées : une PR qui attend bloque un collègue.