Next.js9 min de lecture
Structure d'un projet Next.js
Le même squelette sur tous nos fronts : App Router, Server Components par défaut, code rangé par fonctionnalité. Tu sais où chercher avant même d'ouvrir le projet.
Notre stack front
| Besoin | Choix standard |
|---|---|
| Framework | Next.js 16 (App Router, Turbopack), React 19.2, TypeScript strict |
| Interface | Tailwind CSS 4 et composants shadcn/ui copiés dans components/ui |
| Formulaires et validation | Zod 4 + React Hook Form, Server Actions |
| Traductions | next-intl 4, fichiers JSON par langue et par écran |
| Données | fetch côté serveur vers l'API Laravel, TanStack Query seulement pour le temps réel côté client |
| Tests | Vitest + Testing Library, Playwright pour les parcours |
| Qualité | ESLint (flat config), Prettier, Husky + lint-staged |
L'arborescence
Le dossier app ne contient que du routage. Le code métier vit dans features, un dossier par fonctionnalité, pour que tout ce qui concerne les factures soit au même endroit.
Server ou Client Component ?
Tout composant est un Server Component par défaut : il s'exécute sur le serveur et n'envoie pas de JavaScript au navigateur. On n'ajoute "use client" que lorsque c'est indispensable, sur la plus petite feuille possible.
- Utilise-t-il useState, useEffect ou un autre hook d'état ?
À éviter
'use client'; // the whole page ships to the browser
export default function InvoicesPage() {
const [invoices, setInvoices] = useState([]);
useEffect(() => {
fetch('/api/invoices') // waterfall after hydration
.then((r) => r.json())
.then(setInvoices);
}, []);
return <InvoiceTable invoices={invoices} />;
}À faire
// Server Component by default: no 'use client'
export default async function InvoicesPage() {
const invoices = await getInvoices(); // runs on the server
return (
<>
<InvoiceFilters /> {/* small client leaf */}
<InvoiceTable invoices={invoices} />
</>
);
}Le modèle à reproduire
La page serveur lit les searchParams, délègue le chargement à un composant enveloppé dans Suspense et n'hydrate que le filtre interactif.
import { getTranslations } from 'next-intl/server';
import { Suspense } from 'react';
import { InvoiceFilters } from '@/features/invoices/components/invoice-filters';
import { InvoiceTable } from '@/features/invoices/components/invoice-table';
import { InvoiceTableSkeleton } from '@/features/invoices/components/invoice-table.skeleton';
type Props = {
params: Promise<{ locale: string }>;
searchParams: Promise<{ status?: string; page?: string }>;
};
export default async function InvoicesPage({ searchParams }: Props) {
const { status, page } = await searchParams;
const t = await getTranslations('invoices');
return (
<>
<h1 className="text-2xl font-semibold">{t('title')}</h1>
<InvoiceFilters /> {/* client leaf: interactive */}
<Suspense key={`${status}-${page}`} fallback={<InvoiceTableSkeleton />}>
<InvoiceTable status={status} page={Number(page ?? 1)} /> {/* server: fetches */}
</Suspense>
</>
);
}'use client';
import { useTranslations } from 'next-intl';
import { usePathname, useRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
const statuses = ['draft', 'sent', 'paid'] as const;
export function InvoiceFilters() {
const t = useTranslations('invoices.filters');
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [pending, startTransition] = useTransition();
function select(status: string) {
const next = new URLSearchParams(params);
next.set('status', status);
next.delete('page');
startTransition(() => router.replace(`${pathname}?${next}`));
}
return (
<div role="group" aria-label={t('label')} aria-busy={pending}>
{statuses.map((s) => (
<button key={s} type="button" onClick={() => select(s)} aria-pressed={params.get('status') === s}>
{t(s)}
</button>
))}
</div>
);
}import 'server-only'; // build fails if a client component imports this file
import { apiFetch } from '@/lib/api-client';
import { invoiceListSchema } from '../schemas';
export async function getInvoices(params: { status?: string; page: number }) {
const query = new URLSearchParams({ page: String(params.page), ...(params.status && { status: params.status }) });
const json = await apiFetch(`/v1/invoices?${query}`, { next: { tags: ['invoices'] } });
return invoiceListSchema.parse(json);
}TypeScript et conventions
Le mode strict attrape les erreurs avant la revue. noUncheckedIndexedAccess oblige à gérer le cas où un élément de tableau n'existe pas.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"paths": { "@/*": ["./src/*"] }
}
}- Exports nommés partout, sauf
page.tsx,layout.tsxet les fichiers spéciaux de Next.js. - Props typées avec un
type Propsau-dessus du composant, pas d'interface vide. - Imports absolus avec
@/, jamais de../../../. - Pas de couleur en dur : uniquement les tokens Tailwind du thème (
bg-primary,text-muted-foreground). - Les secrets ne sont lus que dans des fichiers serveur. Seules les variables
NEXT_PUBLIC_arrivent au navigateur, et elles sont publiques.