Skip to main content

Building a User Registration Workflow

This tutorial walks you through implementing a public user registration workflow. You will learn how to use the generated UsersService to send registration data, manage the mutation state with TanStack Query, and build a validated form using React Hook Form and Zod.

Prerequisites

Before starting, ensure you have the following dependencies configured in your project:

  • TanStack Query: For managing the API request state.
  • React Hook Form: For form state management.
  • Zod: For schema-based validation.
  • Generated SDK: The UsersService and associated types from frontend/src/client.

Step 1: Understand the Registration Data Model

The registration process requires specific user data defined by the UserRegister type. This type ensures that the frontend sends the correct fields expected by the backend API.

In frontend/src/client/types.gen.ts, the UserRegister type is defined as:

export type UserRegister = {
email: string;
password: string;
full_name?: (string | null);
};

Step 2: Create the Registration Mutation

To handle the asynchronous registration request, use a TanStack Query mutation. This centralizes the logic for calling the API, handling success (navigation), and managing errors.

In frontend/src/hooks/useAuth.ts, the signUpMutation is implemented within the useAuth hook:

import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { type UserRegister, UsersService } from "@/client"
import { handleError } from "@/utils"
import useCustomToast from "./useCustomToast"

const useAuth = () => {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()

const signUpMutation = useMutation({
mutationFn: (data: UserRegister) =>
UsersService.registerUser({ requestBody: data }),
onSuccess: () => {
// Redirect to login page after successful registration
navigate({ to: "/login" })
},
onError: handleError.bind(showErrorToast),
onSettled: () => {
// Invalidate users query to ensure data consistency
queryClient.invalidateQueries({ queryKey: ["users"] })
},
})

return {
signUpMutation,
// ... other auth methods
}
}

The UsersService.registerUser method sends a POST request to /api/v1/users/signup. Note that it expects the data wrapped in a requestBody property.

Step 3: Define the Form Schema and Validation

Use Zod to define a schema for the signup form. This allows you to enforce rules like email format, minimum password length, and password confirmation matching.

In frontend/src/routes/signup.tsx:

import { z } from "zod"

const formSchema = z
.object({
email: z.string().email(),
full_name: z.string().min(1, { message: "Full Name is required" }),
password: z
.string()
.min(1, { message: "Password is required" })
.min(8, { message: "Password must be at least 8 characters" }),
confirm_password: z
.string()
.min(1, { message: "Password confirmation is required" }),
})
.refine((data) => data.password === data.confirm_password, {
message: "The passwords don't match",
path: ["confirm_password"],
})

type FormData = z.infer<typeof formSchema>

Step 4: Implement the Signup Component

Now, integrate the mutation and the form schema into the SignUp component. The component uses useForm from React Hook Form to manage the input states and validation.

In frontend/src/routes/signup.tsx:

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import useAuth from "@/hooks/useAuth"
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { LoadingButton } from "@/components/ui/loading-button"

function SignUp() {
const { signUpMutation } = useAuth()
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
full_name: "",
password: "",
confirm_password: "",
},
})

const onSubmit = (data: FormData) => {
if (signUpMutation.isPending) return

// Exclude confirm_password before sending to the API
const { confirm_password: _confirm_password, ...submitData } = data
signUpMutation.mutate(submitData)
}

return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* ... Repeat for email and password fields ... */}

<LoadingButton
type="submit"
loading={signUpMutation.isPending}
className="w-full"
>
Sign Up
</LoadingButton>
</form>
</Form>
)
}

How it Works

  1. Form Submission: When the user clicks "Sign Up", form.handleSubmit(onSubmit) validates the data against the Zod schema.
  2. Data Preparation: The onSubmit handler extracts confirm_password from the form data, as the UsersService.registerUser method only accepts fields defined in UserRegister.
  3. API Call: signUpMutation.mutate(submitData) triggers the UsersService.registerUser call.
  4. State Management: While the request is in flight, signUpMutation.isPending is true, which the LoadingButton uses to show a spinner and disable itself.
  5. Completion: On success, the user is redirected to the login page. If the email is already registered, the handleError utility displays a toast notification with the error message from the backend.

Next Steps

Once the user is registered, they can proceed to the login flow. You can explore how the loginMutation in useAuth.ts uses LoginService.loginAccessToken to authenticate the newly created user.