Mini-projet — FareMarks#
Gestionnaire de bookmarks taggés. Pour découvrir Prisma, Supabase, server actions Next 16, TanStack Form, Zod, Biome, Shadcn, Sonner.
Commit quand ça te semble adapté (à chaque étape par exemple)
Prérequis#
Node 24, pnpm, compte Supabase, éditeur de code au choix (je recommande Zed) avec extension Biome.
Setup#
1. Créer le projet#
pnpm create next-app@latest faremarks
cd faremarks
TS oui, ESLint non, Tailwind oui, src/ oui, App Router oui, Turbopack
oui, alias @/* oui.
2. Dépendances#
pnpm add @prisma/client @tanstack/react-form zod sonner nuqs clsx \
tailwind-merge class-variance-authority lucide-react
pnpm add -D prisma @biomejs/biome tsx
3. Biome#
pnpm biome init
Remplace biome.json par Annexe A. Test :
pnpm biome check --write.
4. Shadcn#
# Initialise shadcn, avec les styles et paramètres par défaut
pnpm dlx shadcn@latest init
# Commence par ajouter quelques composants, tu pourras en ajouter plus tard si besoin
pnpm dlx shadcn@latest add button card input textarea label badge dialog sonner
Style Default, Neutral, CSS variables oui.
5. Supabase#
Accueil > Get Connected > ORM :
- Transaction pooler (port 6543) →
DATABASE_URL - Direct connection (port 5432) →
DIRECT_URL
6. .env#
Voir Annexe B. Ajoute .env à .gitignore si pas déjà dedans.
7. Prisma#
pnpm prisma init
Remplace prisma/schema.prisma :
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model Bookmark {
id String @id @default(cuid())
url String
title String
createdAt DateTime @default(now())
}
pnpm prisma migrate dev --name init
8. Client Prisma — src/lib/prisma.ts#
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Cela évite de créer N instances en dev (hot reload).
9. Providers — src/app/layout.tsx#
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "@/components/ui/sonner";
// dans le <body>, enveloppe {children} :
<NuqsAdapter>{children}</NuqsAdapter>
<Toaster richColors position="top-right" />
10. Run#
pnpm dev
Étape 1 — CRUD minimal#
À construire :
src/app/page.tsx(Server Component) : liste les bookmarks + form + bouton delete par itemsrc/actions/bookmarks/create.ts(server action) — voir Annexe Csrc/actions/bookmarks/delete.ts(même pattern, prendid)src/components/BookmarkForm.tsx(Client) — voir Annexe Dsrc/components/DeleteButton.tsx(Client,startTransition+ toast)
Schéma Zod déclaré côté server action, types importés côté client via
import type. Toast vert au succès, rouge en erreur.
Docs : TanStack Form · Zod v4
Étape 2 — Enrichir le modèle#
Ajoute au modèle Bookmark :
description String?
updatedAt DateTime @updatedAt
pnpm prisma migrate dev --name add_description_and_updated_at
Lis le SQL généré dans prisma/migrations/.
À faire : champ description (Textarea) dans le form + feature édition
(modale <Dialog> Shadcn ou route /bookmarks/[id]/edit) + action
updateBookmark.
Étape 3 — Tags (many-to-many)#
model Bookmark {
id String @id @default(cuid())
url String
title String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags Tag[]
}
model Tag {
id String @id @default(cuid())
name String @unique
bookmarks Bookmark[]
}
pnpm prisma migrate dev --name add_tags
À faire : input tags (séparés virgule ou Enter), affichage Badges supprimables, création à la volée des tags inexistants. Affiche les tags sur chaque card.
Indice :
await prisma.bookmark.create({
data: {
url,
title,
tags: {
connectOrCreate: tagNames.map((name) => ({
where: { name },
create: { name },
})),
},
},
});
Pour la lecture : include: { tags: true }.
Bonus 1 — Filtre par tag#
Tags cliquables → /?tag=design. URL state géré avec nuqs (typé,
côté serveur + client).
Côté serveur (page d'accueil) — createLoader :
import { createLoader, parseAsString } from "nuqs/server";
const loadSearchParams = createLoader({
tag: parseAsString,
});
export default async function Home({ searchParams }) {
const { tag } = await loadSearchParams(searchParams);
const bookmarks = await prisma.bookmark.findMany({
where: tag ? { tags: { some: { name: tag } } } : undefined,
include: { tags: true },
});
// ...
}
Côté client (sélecteur de tag, reset) — useQueryState :
"use client";
import { useQueryState } from "nuqs";
export function TagFilter() {
const [tag, setTag] = useQueryState("tag");
return tag ? (
<button onClick={() => setTag(null)}>Réinitialiser ({tag})</button>
) : null;
}
Les badges peuvent être de simples <Link href={/?tag=${name}}>, ou alors
appeler setTag(name) sur un bouton depuis un client component.
Bonus 2 — Page détail#
Route src/app/bookmarks/[id]/page.tsx :
export default async function BookmarkDetail({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const bookmark = await prisma.bookmark.findUnique({
where: { id },
include: { tags: true },
});
if (!bookmark) notFound();
}
Rends les titres cliquables vers cette page.
Bonus 3 — Dockeriser l'app#
Produis une image Docker qui run l'app en prod, avec les migrations Prisma appliquées au démarrage.
Pistes :
- mode
output: "standalone"de Next.js Dockerfilemulti-stage (deps → builder → runner) surnode:24-alpine.dockerignore(n'embarque pasnode_modules,.next,.env*,.git)prisma migrate deploy(pasmigrate dev) au démarrage du conteneur (à mettre dans undocker-entrypoint.sh)
Docs : Next.js Docker Prisma in Docker pnpm in Docker
compose.yml pour te simplifier le lancement :
services:
web:
build: .
ports:
- "3000:3000"
env_file:
- .env
Exécuter avec :
docker compose up --build
Seed#
prisma/seed.ts :
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.bookmark.deleteMany();
await prisma.tag.deleteMany();
await prisma.bookmark.create({
data: {
url: "https://nextjs.org",
title: "Next.js",
description: "Le framework React de référence",
tags: {
connectOrCreate: [
{
where: { name: "framework" },
create: { name: "framework" },
},
{ where: { name: "react" }, create: { name: "react" } },
],
},
},
});
// ~10 entrées variées
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
package.json :
"prisma": { "seed": "tsx prisma/seed.ts" }
pnpm prisma db seed
Critères d'auto-évaluation#
-
pnpm tsc --noEmit→ 0 erreur -
pnpm biome check --write→ 0 erreur - Server actions validés avec Zod
-
revalidatePathaprès chaque mutation - Pas de
anyouunknown - UI responsive
- Messages de commit clairs
- App utilisable de bout en bout
Annexes#
Annexe A — biome.json#
{
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"ignoreUnknown": false,
"includes": [
"**",
"!**/pnpm-lock.yaml",
"!**/next-env.d.ts",
"!**/migrations/**/*",
"!**/node_modules/**/*",
"!**/.next",
"!**/dist"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"lineEnding": "lf",
"lineWidth": 80
},
"linter": {
"enabled": true,
"domains": { "next": "recommended" },
"rules": {
"recommended": true,
"style": {
"useImportType": "on",
"useExportType": "on",
"useNodejsImportProtocol": "error",
"useConst": "on",
"noNonNullAssertion": "warn"
},
"nursery": {
"useSortedClasses": {
"level": "on",
"fix": "safe",
"options": { "functions": ["cn", "twMerge", "cva"] }
}
},
"suspicious": {
"noExplicitAny": "error"
}
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteStyle": "double",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "always"
}
},
"assist": {
"enabled": true,
"actions": { "source": { "organizeImports": "on" } }
}
}
Annexe B — .env#
DATABASE_URL="<à récupérer de supabase>"
DIRECT_URL="<à récupérer de supabase>"
Annexe C — Squelette server action#
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
export const CreateBookmarkSchema = z.object({
url: z.url("URL invalide"),
title: z.string().min(1, "Titre requis"),
});
export type CreateBookmarkInput = z.infer<typeof CreateBookmarkSchema>;
type ActionResult = { success: true } | { success: false; error: string };
export async function createBookmark(
input: CreateBookmarkInput,
): Promise<ActionResult> {
const parsed = CreateBookmarkSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: parsed.error.message };
}
try {
await prisma.bookmark.create({ data: parsed.data });
} catch (e) {
console.error(e);
return { success: false, error: "Erreur lors de la création" };
}
revalidatePath("/");
return { success: true };
}
Annexe D — Squelette client form#
Lire la documentation : https://ui.shadcn.com/docs/forms/tanstack-form
"use client";
import { useForm } from "@tanstack/react-form";
import { useTransition } from "react";
import { toast } from "sonner";
import {
createBookmark,
CreateBookmarkSchema,
} from "@/actions/bookmarks/create";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
export function BookmarkForm() {
const [isPending, startTransition] = useTransition();
const form = useForm({
defaultValues: { url: "", title: "" },
validators: { onChange: CreateBookmarkSchema },
onSubmit: ({ value }) => {
startTransition(async () => {
const result = await createBookmark(value);
if (result.success) {
toast.success("Bookmark ajouté");
form.reset();
} else {
toast.error(result.error);
}
});
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit();
}}
className="flex flex-col gap-3"
>
<form.Field name="url">
{(field) => (
<div>
<Label htmlFor={field.name}>URL</Label>
<Input
id={field.name}
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="https://..."
/>
{field.state.meta.errors.length > 0 && (
<p className="text-sm text-red-500">
{field.state.meta.errors[0]?.message}
</p>
)}
</div>
)}
</form.Field>
{/* À toi : champ title */}
<Button type="submit" disabled={isPending}>
{isPending ? "..." : "Ajouter"}
</Button>
</form>
);
}
Annexe E — Commandes utiles#
| Commande | Effet |
|---|---|
pnpm dev |
Dev server |
pnpm build |
Build prod |
pnpm biome check --write |
Lint + format auto-fix |
pnpm biome ci |
Check sans modifier |
pnpm tsc --noEmit |
Type-check |
pnpm prisma studio |
GUI BDD (au lieu de supabase) |
pnpm prisma migrate dev --name xxx |
Nouvelle migration dev |
pnpm prisma migrate reset |
Reset BDD (data perdue) |
pnpm prisma generate |
Régénère le client |
pnpm prisma db seed |
Lance le seed |