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.