Adding Authentication to React AppsEntry 3 of 11
Sending Credentials in a Route Action
The auth route's action reads the mode from the request URL, sends the credentials to /login or /signup, and handles validation errors.
Notes
- Actions receive the
request, not hooks, so read query params withnew URL(request.url).searchParams. - Default to
'login'ifmodeis missing, and reject unknown modes. - Backend responses to handle:
- 422 (invalid input) or 401 (wrong credentials): return the error data so the form can show it with
useActionData(). - Other errors: throw, so the
errorElementrenders. - Success: store the token (Entry 4) and
redirect('/').
- 422 (invalid input) or 401 (wrong credentials): return the error data so the form can show it with
- Keep the HTTP call in a service module, matching Angular's service convention.
What / Why / When
- What
Route-level login/signup handling.
- Why
Submission, error feedback, and redirect use the same router patterns as other forms.
- When
The auth form submits to a backend that returns a token.
Code compare
// services/auth.tsconst API = "http://localhost:8080";export type AuthMode = "login" | "signup";export type Credentials = { email: string; password: string };export type AuthErrors = { message: string; errors?: Record<string, string> };export type AuthResult = { token: string } | AuthErrors;export async function authenticate(mode: AuthMode, credentials: Credentials): Promise<AuthResult> { const response = await fetch(`${API}/${mode}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentials), }); if (response.status === 422 || response.status === 401) { return response.json(); // { message, errors? } } if (!response.ok) throw new Error("Could not authenticate user."); const { token } = await response.json(); return { token };}// pages/Authentication.tsximport { data, redirect, type ActionFunctionArgs } from "react-router";import AuthForm from "../components/AuthForm";import { authenticate } from "../services/auth";import { storeAuthToken } from "../util/auth";export default function AuthenticationPage() { return <AuthForm />;}export async function action({ request }: ActionFunctionArgs) { const searchParams = new URL(request.url).searchParams; const mode = searchParams.get("mode") || "login"; if (mode !== "login" && mode !== "signup") { throw data({ message: "Unsupported mode." }, { status: 422 }); } const formData = await request.formData(); const result = await authenticate(mode, { email: String(formData.get("email")), password: String(formData.get("password")), }); if (!("token" in result)) return result; // validation / credential errors → useActionData storeAuthToken(result.token); return redirect("/");}// components/AuthForm.tsx (additions)import { Form, Link, useActionData, useNavigation, useSearchParams } from "react-router";import type { AuthErrors } from "../services/auth";export default function AuthForm() { const [searchParams] = useSearchParams(); const actionData = useActionData() as AuthErrors | undefined; const navigation = useNavigation(); const isLogin = searchParams.get("mode") === "login"; const isSubmitting = navigation.state === "submitting"; return ( <Form method="post"> <h1>{isLogin ? "Log in" : "Create a new user"}</h1> {actionData?.errors && ( <ul> {Object.values(actionData.errors).map((err) => ( <li key={err}>{err}</li> ))} </ul> )} {actionData?.message && <p>{actionData.message}</p>} {/* ...email and password inputs */} <div> <Link to={`?mode=${isLogin ? "signup" : "login"}`}> {isLogin ? "Create new user" : "Login"} </Link> <button disabled={isSubmitting}>{isSubmitting ? "Submitting..." : "Save"}</button> </div> </Form> );}// services/auth.service.ts (excerpt; full version in Entry 4)authenticate(mode: 'login' | 'signup', credentials: { email: string; password: string }) { return this.http .post<{ token: string }>(`${this.api}/${mode}`, credentials) .pipe(tap(({ token }) => this.storeToken(token)));}// auth.component.ts (additions)export class AuthComponent { private authService = inject(AuthService); private router = inject(Router); protected isSubmitting = signal(false); protected serverMessage = signal<string | null>(null); protected serverErrors = signal<string[]>([]); onSubmit() { if (this.form.invalid) return; this.isSubmitting.set(true); this.authService .authenticate(this.isLogin() ? "login" : "signup", this.form.getRawValue()) .subscribe({ next: () => this.router.navigate(["/"]), error: (err: HttpErrorResponse) => { if (err.status === 422 || err.status === 401) { this.serverMessage.set(err.error.message); this.serverErrors.set(Object.values(err.error.errors ?? {})); } this.isSubmitting.set(false); }, }); }}Differences and gotchas
- React reads the mode from
request.urlinside the action; Angular reads it from the component'smodeinput. - Return (don't throw) expected auth errors in React. Throwing sends the user to the error page instead of showing the message on the form.
Quick reference
| Task | React | Angular |
|---|---|---|
| Read mode in handler | new URL(request.url).searchParams.get('mode') | this.mode() |
| Send credentials | authenticate() service in action | AuthService.authenticate() |
| Show errors | return result → useActionData() | error callback → signals |
| After success | redirect('/') | router.navigate(['/']) |