Formulaires d’inscription et de connexion Remix

Créez des formulaires ergonomiques pour l’inscription et la connexion avec validation côté client et serveur.

6 min read

Introduction

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 :

  • Zod pour définir et valider nos schémas de données.
  • Conform (@conform-to/react + @conform-to/zod) pour une gestion ergonomique des formulaires.
  • Les loaders/actions de Remix et les sessions pour authentifier l’utilisateur.

À l’issue de la leçon, tu sauras :

  • Définir un schéma Zod synchronisé et asynchrone.
  • Composer action Remix pour valider, gérer les erreurs et créer la session.
  • Afficher les erreurs de validation côté client.
  • Structurer un formulaire réutilisable avec Conform.

Login : schéma et action

1. Définir le schéma Zod

On commence par formaliser les champs attendus et les contraintes.

app/utils/schemas/login.schema.ts
1
import { z } from "zod";
2
3
export const LoginSchema = z.object({
4
email: z.string().email("Adresse email invalide"),
5
password: z.string().min(8, "Le mot de passe doit faire ≥ 8 caractères"),
6
});

2. Action function Remix

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.

app/routes/login.tsx
1
import { Form, Link, json, useActionData, useNavigation }
2
from "@remix-run/react";
3
import type { ActionFunctionArgs } from "@remix-run/node";
4
import { useForm, getFormProps, getInputProps }
5
from "@conform-to/react";
6
import { parseWithZod } from "@conform-to/zod";
7
import { LoginSchema } from "~/utils/schemas/login.schema";
8
import { checkIfUserExists, isUserPasswordValid, logUser }
9
from "~/strapi.server";
10
import { createUserSession } from "~/sessions.server";
11
12
export const action = async ({ request }: ActionFunctionArgs) => {
13
const formData = await request.formData();
14
const submission = await parseWithZod(formData, {
15
schema: LoginSchema.superRefine(async (data, ctx) => {
16
const exists = await checkIfUserExists({ email: data.email });
17
if (!exists) {
18
ctx.addIssue({
19
code: "custom",
20
message: "Utilisateur inconnu.",
21
path: ["email"],
22
});
23
}
24
const { isPasswordValid } = await isUserPasswordValid({
25
email: data.email,
26
currentPassword: data.password,
27
});
28
if (!isPasswordValid) {
29
ctx.addIssue({
30
code: "custom",
31
message: "Mot de passe incorrect.",
32
path: ["password"],
33
});
34
}
35
}),
36
async: true,
37
});
38
39
if (submission.status !== "success") {
40
return json({ result: submission.reply() });
41
}
42
43
const { jwt } = await logUser({ loginData: submission.value });
44
return createUserSession({
45
request,
46
strapiUserToken: jwt,
47
});
48
};

UI du formulaire de connexion

On utilise useForm pour connecter Conform à notre UI. Les erreurs et l’état de soumission sont gérés automatiquement.

app/routes/login.tsx
1
export default function Login() {
2
const actionData = useActionData<typeof action>();
3
const navigation = useNavigation();
4
const isSubmitting = navigation.state === "submitting";
5
6
const [form, fields] = useForm({
7
lastResult: actionData?.result,
8
onValidate({ formData }) {
9
return parseWithZod(formData, { schema: LoginSchema });
10
},
11
});
12
13
return (
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
<input
21
{...getInputProps(fields.email, { type: "email" })}
22
className="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>
28
29
<div>
30
<label htmlFor="password">Mot de passe</label>
31
<input
32
{...getInputProps(fields.password, { type: "password" })}
33
className="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>
39
40
<button
41
type="submit"
42
className="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">
49
Pas de compte ?{" "}
50
<Link to="/register" className="text-blue-600 hover:underline">
51
Inscription
52
</Link>
53
</p>
54
</div>
55
);
56
}

Inscription : schéma, action et UI

1. Schéma Zod pour l’inscription

On ajoute la confirmation de mot de passe et on vérifie l’unicité de l’email.

app/utils/schemas/register.schema.ts
1
import { z } from "zod";
2
3
export const RegisterSchema = z
4
.object({
5
email: z.string().email("Email invalide"),
6
password: z.string().min(8, "8 caractères min."),
7
confirmPassword: z.string(),
8
})
9
.refine((data) => data.password === data.confirmPassword, {
10
message: "Les mots de passe doivent correspondre",
11
path: ["confirmPassword"],
12
});

2. Action function pour l’inscription

On s’assure qu’aucun user n’existe déjà, puis on crée l’utilisateur dans Strapi.

app/routes/register.tsx
1
import { Form, useActionData, useNavigation, Link }
2
from "@remix-run/react";
3
import { useForm, getFormProps, getInputProps }
4
from "@conform-to/react";
5
import { parseWithZod } from "@conform-to/zod";
6
import type { ActionFunctionArgs } from "@remix-run/node";
7
import { RegisterSchema } from "~/utils/schemas/register.schema";
8
import { createUserInStrapi } from "~/strapi.server";
9
import { createUserSession } from "~/sessions.server";
10
11
export const action = async ({ request }: ActionFunctionArgs) => {
12
const formData = await request.formData();
13
const submission = await parseWithZod(formData, {
14
schema: RegisterSchema.superRefine(async (data, ctx) => {
15
const exists = await checkIfUserExists({ email: data.email });
16
if (exists) {
17
ctx.addIssue({
18
code: "custom",
19
message: "Email déjà utilisé",
20
path: ["email"],
21
});
22
}
23
}),
24
async: true,
25
});
26
27
if (submission.status !== "success") {
28
return json({ result: submission.reply() });
29
}
30
31
const user = await createUserInStrapi({
32
email: submission.value.email,
33
password: submission.value.password,
34
});
35
36
return createUserSession({
37
request,
38
strapiUserToken: user.jwt,
39
});
40
};

3. UI du formulaire d’inscription

app/routes/register.tsx
1
export default function Register() {
2
const actionData = useActionData<typeof action>();
3
const navigation = useNavigation();
4
const isSubmitting = navigation.state === "submitting";
5
6
const [form, fields] = useForm({
7
lastResult: actionData?.result,
8
onValidate({ formData }) {
9
return parseWithZod(formData, { schema: RegisterSchema });
10
},
11
});
12
13
return (
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
<input
21
{...getInputProps(fields.email, { type: "email" })}
22
className="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>
28
29
<div>
30
<label>Mot de passe</label>
31
<input
32
{...getInputProps(fields.password, { type: "password" })}
33
className="w-full px-3 py-2 border rounded"
34
/>
35
</div>
36
37
<div>
38
<label>Confirme mot de passe</label>
39
<input
40
{...getInputProps(fields.confirmPassword, { type: "password" })}
41
className="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>
49
50
<button
51
type="submit"
52
className="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">
59
Déjà un compte ?{" "}
60
<Link to="/login" className="text-blue-600 hover:underline">
61
Connexion
62
</Link>
63
</p>
64
</div>
65
);
66
}

Points clés

  • Zod + Conform gèrent à la fois les validations synchrones et asynchrones.
  • parseWithZod(formData, {...}) renvoie un objet submission au format standard.
  • Utilise fields.xxx.errors pour afficher les erreurs sous chaque champ.
  • Crée la session avec createUserSession après un login/inscription réussi.
  • Conform simplifie la liaison entre ton UI React et la validation Zod sans boilerplate.

Exercices rapides

  1. Crée un formulaire “Mot de passe oublié” avec un schéma Zod qui valide l’email.
  2. Ajoute un champ username à l’inscription, assure-toi qu’il est unique en backend.
  3. Personnalise la page de connexion pour rediriger l’utilisateur vers /dashboard
    après réussite de la connexion.