Exemples d'implémentation¶
Ce chapitre présente deux projets complets et fonctionnels. Le premier est une API REST CRUD avec Fastify et TypeScript, persistee en SQLite via Prisma. Le second est une application full-stack avec Next.js utilisant les Server Components et les routes API.
Projet commun : API REST CRUD avec Fastify¶
Structure du projet¶
fastify-api/
├── prisma/
│ ├── schema.prisma
│ └── dev.db
├── src/
│ ├── db.ts # client Prisma singleton
│ ├── schemas.ts # schemas Zod
│ ├── routes/
│ │ └── items.ts # routes CRUD
│ └── server.ts # entree principale
├── package.json
└── tsconfig.json
Configuration initiale¶
// package.json
{
"name": "fastify-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:migrate": "prisma migrate dev",
"db:generate": "prisma generate"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"fastify": "^5.0.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"prisma": "^5.20.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Item {
id Int @id @default(autoincrement())
name String
done Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Client Prisma¶
// src/db.ts — Client Prisma singleton pour eviter les connexions multiples
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
Schémas de validation Zod¶
// src/schemas.ts — Schemas Zod partages entre validation et inference de types
import { z } from 'zod';
// Schema de creation : name obligatoire, done optionnel
export const CreateItemSchema = z.object({
name: z.string().min(1, 'Le nom est requis').max(200, 'Nom trop long'),
done: z.boolean().optional().default(false),
});
// Schema de mise a jour : tous les champs sont optionnels
export const UpdateItemSchema = z
.object({
name: z.string().min(1).max(200),
done: z.boolean(),
})
.partial()
.refine((data) => Object.keys(data).length > 0, {
message: 'Au moins un champ est requis pour la mise a jour',
});
// Schema de parametre ID dans l'URL
export const IdParamSchema = z.object({
id: z.coerce.number().int().positive('ID doit etre un entier positif'),
});
// Types inferres depuis les schemas
export type CreateItemInput = z.infer<typeof CreateItemSchema>;
export type UpdateItemInput = z.infer<typeof UpdateItemSchema>;
export type IdParam = z.infer<typeof IdParamSchema>;
Routes CRUD¶
// src/routes/items.ts — Routes Fastify avec validation Zod manuelle
import type { FastifyPluginAsync } from 'fastify';
import { prisma } from '../db.js';
import {
CreateItemSchema,
UpdateItemSchema,
IdParamSchema,
} from '../schemas.js';
export const itemsPlugin: FastifyPluginAsync = async (app) => {
// GET /items — Liste tous les items
app.get('/items', async (_req, reply) => {
const items = await prisma.item.findMany({
orderBy: { createdAt: 'desc' },
});
return reply.send(items);
});
// GET /items/:id — Recupere un item par son ID
app.get<{ Params: { id: string } }>('/items/:id', async (req, reply) => {
const params = IdParamSchema.safeParse(req.params);
if (!params.success) {
return reply.status(400).send({ error: params.error.flatten() });
}
const item = await prisma.item.findUnique({ where: { id: params.data.id } });
if (!item) {
return reply.status(404).send({ error: 'Item non trouve' });
}
return reply.send(item);
});
// POST /items — Cree un nouvel item
app.post('/items', async (req, reply) => {
const body = CreateItemSchema.safeParse(req.body);
if (!body.success) {
return reply.status(400).send({ error: body.error.flatten() });
}
const item = await prisma.item.create({ data: body.data });
return reply.status(201).send(item);
});
// PUT /items/:id — Met a jour un item
app.put<{ Params: { id: string } }>('/items/:id', async (req, reply) => {
const params = IdParamSchema.safeParse(req.params);
if (!params.success) {
return reply.status(400).send({ error: params.error.flatten() });
}
const body = UpdateItemSchema.safeParse(req.body);
if (!body.success) {
return reply.status(400).send({ error: body.error.flatten() });
}
try {
const item = await prisma.item.update({
where: { id: params.data.id },
data: body.data,
});
return reply.send(item);
} catch {
// Prisma P2025 : enregistrement non trouve
return reply.status(404).send({ error: 'Item non trouve' });
}
});
// DELETE /items/:id — Supprime un item
app.delete<{ Params: { id: string } }>('/items/:id', async (req, reply) => {
const params = IdParamSchema.safeParse(req.params);
if (!params.success) {
return reply.status(400).send({ error: params.error.flatten() });
}
try {
await prisma.item.delete({ where: { id: params.data.id } });
return reply.status(204).send();
} catch {
return reply.status(404).send({ error: 'Item non trouve' });
}
});
};
Serveur principal¶
// src/server.ts — Point d'entree Fastify
import Fastify from 'fastify';
import { itemsPlugin } from './routes/items.js';
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? 'info',
transport:
process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
},
});
// Enregistrement du plugin de routes
await app.register(itemsPlugin);
// Gestion gracieuse de l'arret
const shutdown = async () => {
await app.close();
process.exit(0);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
try {
await app.listen({ port: 3000, host: '0.0.0.0' });
} catch (err) {
app.log.error(err);
process.exit(1);
}
Projet spécifique : SPA full-stack avec Next.js 15¶
Structure du projet¶
nextjs-app/
├── app/
│ ├── layout.tsx
│ ├── page.tsx # liste des items (Server Component)
│ ├── items/
│ │ ├── [id]/
│ │ │ └── page.tsx # detail d'un item
│ │ └── new/
│ │ └── page.tsx # formulaire de creation
│ └── api/
│ └── items/
│ ├── route.ts # GET liste + POST creation
│ └── [id]/
│ └── route.ts # GET detail + PUT + DELETE
├── lib/
│ ├── db.ts
│ └── schemas.ts
└── components/
└── ItemForm.tsx # formulaire client
Route API — liste et création¶
// app/api/items/route.ts — Route API Next.js 15
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { CreateItemSchema } from '@/lib/schemas';
// GET /api/items
export async function GET() {
const items = await prisma.item.findMany({
orderBy: { createdAt: 'desc' },
});
return NextResponse.json(items);
}
// POST /api/items
export async function POST(request: Request) {
const body = await request.json();
const parsed = CreateItemSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.flatten() },
{ status: 400 }
);
}
const item = await prisma.item.create({ data: parsed.data });
return NextResponse.json(item, { status: 201 });
}
Page liste — Server Component¶
// app/page.tsx — Server Component : fetch direct sans useState
import Link from 'next/link';
interface Item {
id: number;
name: string;
done: boolean;
}
// Pas de 'use client' — ce composant s'execute sur le serveur
async function getItems(): Promise<Item[]> {
const res = await fetch('http://localhost:3000/api/items', {
cache: 'no-store', // desactive le cache pour donnees dynamiques
});
if (!res.ok) throw new Error('Echec du chargement des items');
return res.json();
}
export default async function HomePage() {
const items = await getItems();
return (
<main style={{ maxWidth: 600, margin: '2rem auto', padding: '0 1rem' }}>
<h1>Items</h1>
<Link href="/items/new">Nouvel item</Link>
<ul style={{ marginTop: '1rem', listStyle: 'none', padding: 0 }}>
{items.map((item) => (
<li key={item.id} style={{ marginBottom: '0.5rem' }}>
<Link href={`/items/${item.id}`}>
<span style={{ textDecoration: item.done ? 'line-through' : 'none' }}>
{item.name}
</span>
</Link>
</li>
))}
</ul>
</main>
);
}
Formulaire de création — Client Component¶
// components/ItemForm.tsx — Composant client avec gestion du formulaire
'use client';
import { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
export default function ItemForm() {
const router = useRouter();
const [name, setName] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error?.fieldErrors?.name?.[0] ?? 'Erreur de creation');
return;
}
// Redirection vers la page principale apres creation
router.push('/');
router.refresh(); // invalide le cache du Server Component
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nom de l'item"
required
style={{ flex: 1, padding: '0.5rem' }}
/>
<button type="submit" disabled={submitting}>
{submitting ? 'Creation...' : 'Creer'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
}
Server Components vs Client Components
Par défaut dans Next.js 15, tous les composants sont des Server Components (pas de 'use client'). Ajouter 'use client' uniquement quand le composant a besoin de useState, useEffect, d'événements DOM, ou d'API navigateur. Les Server Components peuvent fetcher des données directement, ce qui élimine les waterfalls client.