Créez des formulaires ergonomiques pour l’inscription et la connexion avec validation côté client et serveur.
Dans cette leçon, on va voir comment mettre en place des formulaires d’inscription et de connexion dans un projet Remix avec un backend Strapi 5. On utilisera :
@conform-to/react
+ @conform-to/zod
) pour une gestion ergonomique des formulaires.À l’issue de la leçon, tu sauras :
action
Remix pour valider, gérer les erreurs et créer la session.On commence par formaliser les champs attendus et les contraintes.
1import { z } from "zod";23export const LoginSchema = z.object({4email: z.string().email("Adresse email invalide"),5password: z.string().min(8, "Le mot de passe doit faire ≥ 8 caractères"),6});
L’action
récupère le FormData
, l’envoie à Conform/Zod, puis vérifie l’existence de l’utilisateur et la validité du mot de passe via Strapi.
1import { Form, Link, json, useActionData, useNavigation }2from "@remix-run/react";3import type { ActionFunctionArgs } from "@remix-run/node";4import { useForm, getFormProps, getInputProps }5from "@conform-to/react";6import { parseWithZod } from "@conform-to/zod";7import { LoginSchema } from "~/utils/schemas/login.schema";8import { checkIfUserExists, isUserPasswordValid, logUser }9from "~/strapi.server";10import { createUserSession } from "~/sessions.server";1112export const action = async ({ request }: ActionFunctionArgs) => {13const formData = await request.formData();14const submission = await parseWithZod(formData, {15schema: LoginSchema.superRefine(async (data, ctx) => {16const exists = await checkIfUserExists({ email: data.email });17if (!exists) {18ctx.addIssue({19code: "custom",20message: "Utilisateur inconnu.",21path: ["email"],22});23}24const { isPasswordValid } = await isUserPasswordValid({25email: data.email,26currentPassword: data.password,27});28if (!isPasswordValid) {29ctx.addIssue({30code: "custom",31message: "Mot de passe incorrect.",32path: ["password"],33});34}35}),36async: true,37});3839if (submission.status !== "success") {40return json({ result: submission.reply() });41}4243const { jwt } = await logUser({ loginData: submission.value });44return createUserSession({45request,46strapiUserToken: jwt,47});48};
On utilise useForm
pour connecter Conform à notre UI. Les erreurs et l’état de soumission sont gérés automatiquement.
1export default function Login() {2const actionData = useActionData<typeof action>();3const navigation = useNavigation();4const isSubmitting = navigation.state === "submitting";56const [form, fields] = useForm({7lastResult: actionData?.result,8onValidate({ formData }) {9return parseWithZod(formData, { schema: LoginSchema });10},11});1213return (14<div className="max-w-md mx-auto mt-8">15<h2 className="text-2xl font-bold mb-4">Connexion</h2>16<Form method="post" {...getFormProps(form)}>17<fieldset disabled={isSubmitting} className="space-y-4">18<div>19<label htmlFor="email">Email</label>20<input21{...getInputProps(fields.email, { type: "email" })}22className="w-full px-3 py-2 border rounded"23/>24{fields.email.errors && (25<p className="text-red-500">{fields.email.errors}</p>26)}27</div>2829<div>30<label htmlFor="password">Mot de passe</label>31<input32{...getInputProps(fields.password, { type: "password" })}33className="w-full px-3 py-2 border rounded"34/>35{fields.password.errors && (36<p className="text-red-500">{fields.password.errors}</p>37)}38</div>3940<button41type="submit"42className="w-full bg-blue-500 text-white py-2 rounded"43>44{isSubmitting ? "Connexion..." : "Se connecter"}45</button>46</fieldset>47</Form>48<p className="mt-4 text-center">49Pas de compte ?{" "}50<Link to="/register" className="text-blue-600 hover:underline">51Inscription52</Link>53</p>54</div>55);56}
Conform synchronise automatiquement l’état du formulaire et les erreurs
Zod + Conform gèrent validation sync/async & renvoient un objet
reply()
.
On ajoute la confirmation de mot de passe et on vérifie l’unicité de l’email.
1import { z } from "zod";23export const RegisterSchema = z4.object({5email: z.string().email("Email invalide"),6password: z.string().min(8, "8 caractères min."),7confirmPassword: z.string(),8})9.refine((data) => data.password === data.confirmPassword, {10message: "Les mots de passe doivent correspondre",11path: ["confirmPassword"],12});
On s’assure qu’aucun user n’existe déjà, puis on crée l’utilisateur dans Strapi.
1import { Form, useActionData, useNavigation, Link }2from "@remix-run/react";3import { useForm, getFormProps, getInputProps }4from "@conform-to/react";5import { parseWithZod } from "@conform-to/zod";6import type { ActionFunctionArgs } from "@remix-run/node";7import { RegisterSchema } from "~/utils/schemas/register.schema";8import { createUserInStrapi } from "~/strapi.server";9import { createUserSession } from "~/sessions.server";1011export const action = async ({ request }: ActionFunctionArgs) => {12const formData = await request.formData();13const submission = await parseWithZod(formData, {14schema: RegisterSchema.superRefine(async (data, ctx) => {15const exists = await checkIfUserExists({ email: data.email });16if (exists) {17ctx.addIssue({18code: "custom",19message: "Email déjà utilisé",20path: ["email"],21});22}23}),24async: true,25});2627if (submission.status !== "success") {28return json({ result: submission.reply() });29}3031const user = await createUserInStrapi({32email: submission.value.email,33password: submission.value.password,34});3536return createUserSession({37request,38strapiUserToken: user.jwt,39});40};
1export default function Register() {2const actionData = useActionData<typeof action>();3const navigation = useNavigation();4const isSubmitting = navigation.state === "submitting";56const [form, fields] = useForm({7lastResult: actionData?.result,8onValidate({ formData }) {9return parseWithZod(formData, { schema: RegisterSchema });10},11});1213return (14<div className="max-w-md mx-auto mt-8">15<h2 className="text-2xl font-bold mb-4">Inscription</h2>16<Form method="post" {...getFormProps(form)}>17<fieldset disabled={isSubmitting} className="space-y-4">18<div>19<label>Email</label>20<input21{...getInputProps(fields.email, { type: "email" })}22className="w-full px-3 py-2 border rounded"23/>24{fields.email.errors && (25<p className="text-red-500">{fields.email.errors}</p>26)}27</div>2829<div>30<label>Mot de passe</label>31<input32{...getInputProps(fields.password, { type: "password" })}33className="w-full px-3 py-2 border rounded"34/>35</div>3637<div>38<label>Confirme mot de passe</label>39<input40{...getInputProps(fields.confirmPassword, { type: "password" })}41className="w-full px-3 py-2 border rounded"42/>43{fields.confirmPassword.errors && (44<p className="text-red-500">45{fields.confirmPassword.errors}46</p>47)}48</div>4950<button51type="submit"52className="w-full bg-green-500 text-white py-2 rounded"53>54{isSubmitting ? "Inscription..." : "S’inscrire"}55</button>56</fieldset>57</Form>58<p className="mt-4 text-center">59Déjà un compte ?{" "}60<Link to="/login" className="text-blue-600 hover:underline">61Connexion62</Link>63</p>64</div>65);66}
Toujours transmettre le mot de passe en HTTPS et ne jamais le stocker en clair.
parseWithZod(formData, {...})
renvoie un objet submission
au format standard.fields.xxx.errors
pour afficher les erreurs sous chaque champ.createUserSession
après un login/inscription réussi.username
à l’inscription, assure-toi qu’il est unique en backend./dashboard