Building a Paytm-Style Payments Frontend with React, TanStack Router, React Query, and Shadcn UI

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.
What Are We Building?
A mini Paytm-style wallet application where users can:
Create an account
Login securely
View wallet balance
Search other users
Transfer money
Tech Stack
React
TypeScript
Vite
Bun
TanStack Router
TanStack Query
React Hook Form
Axios
Shadcn UI
Tailwind CSS
Step1: Project Setup
Create the Project
bun create vite
Select:
React
TypeScript
Install dependencies:
bun install
Run the development server:
bun run dev
Step2: Setting Up TanStack Router
For routing, I decided to use TanStack Router. One thing I really like about TanStack Router it supports file-based routing. Routes are generated from files inside the routes directory, making navigation more scalable as applications grow.
I followed the official TanStack Router manual installation guide:
TanStack Router Manual Installation Guide
After installation, let's understand a few core concepts.
Understanding TanStack Router Fundamentals
let's understand a few concepts that make it different from React Router.
- TanStack Router is File-Based
Instead of manually defining routes like:
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/login" element={<Login />} />
you create route files:
routes/
├── login.tsx
├── dashboard.tsx
TanStack Router automatically generates the route configuration from your file structure.
This approach is very similar to the App Router introduced in Next.js.
As your application grows, file-based routing becomes much easier to manage than maintaining a large route configuration file.
2. __root.tsx is the Root Layout
Every TanStack Router application starts with a root route.
routes/
└── __root.tsx
All pages are rendered inside this route.
Think of it like:
function App() {
return <Outlet />;
}
Outlet is the placeholder where child routes render.
Example:
function RootLayout() {
return (
<>
<Navbar />
<Outlet />
<TanStackRouterDevtools />
</>
);
}
If a user visits:
/dashboard
TanStack Router renders:
<Navbar />
<Dashboard />
This makes it easy to share layouts, navigation, and global components across multiple pages.
3. WHat is
A small TanStack icon will appear in the browser during development. Clicking it opens the router inspector, making it easier to understand how routes are being resolved.
Since every page is rendered inside __root.tsx, placing the Devtools there makes them available throughout the entire application.
Step 3: Setting Up Shadcn UI
For building the UI, I decided to use Shadcn UI.
Unlike traditional component libraries such as Material UI or Chakra UI, Shadcn UI doesn't install a large component package into your project. Instead, it generates component source code directly inside your application, giving you complete control over customization.
I followed the official installation guide:
Quick Tailwind CSS Refresher
Since Shadcn UI relies heavily on Tailwind CSS, let's quickly revise some Tailwind fundamentals.
What is Tailwind CSS?
Tailwind CSS is a utility-first CSS framework.
Traditional frameworks such as Bootstrap provide pre-built components:
<button class="btn btn-primary">
Save
</button>
Tailwind takes a different approach by providing small utility classes that can be combined to build custom designs:
<button class="bg-blue-500 text-white px-4 py-2 rounded">
Save
</button>
This gives much more flexibility while keeping styling close to the component.
Common Utility Classes
Background & Text Colors
<div class="bg-blue-500 text-white">
Spacing
<div class="p-4 m-2">
| Class | Meaning |
|---|---|
| p-4 | padding |
| px-4 | horizontal padding |
| py-4 | vertical padding |
| m-4 | margin |
| mx-auto | center horizontally |
Width
w-1/2
width: 50%;
Typography
text-lg font-bold text-center
Equivalent CSS:
font-size: 18px;
font-weight: bold;
text-align: center;
Borders & Shadows
<div class="border rounded shadow-lg">
Flexbox
<div class="flex items-center justify-between">
Grid
<div class="grid grid-cols-3 gap-4">
Responsive Design
One of Tailwind's strongest features is responsive utilities.
<div class="text-sm md:text-lg lg:text-2xl">
Screen sizes:
sm:Small screensmd:Tabletslg:Laptopsxl:Desktops2xl:Large displays
Container
<div class="container mx-auto px-4">
containercreates a responsive containermx-autocenters it horizontallypx-4adds horizontal padding
Customizing Tailwind
Tailwind provides default:
Colors
Fonts
Spacing
Breakpoints
These can be customized inside your configuration.
Example:
theme: {
colors: {
primary: "#3490dc",
},
}
Usage:
<div class="bg-primary">
Reusing Styles with @apply
Instead of repeating utilities:
bg-blue-500 text-white px-4 py-2 rounded
You can create reusable classes:
.btn {
@apply bg-blue-500 text-white px-4 py-2 rounded;
}
Then use:
<button class="btn">
Save
</button>
Tailwind's utility-first approach may feel unusual at first, but after building a few components, it becomes one of the fastest ways to create responsive and maintainable user interfaces.
Step4: Creating Login and Signup Pages
Instead of building forms from scratch, I used the pre-built authentication blocks provided by Shadcn UI.
bunx --bun shadcn@latest add login-01
bunx --bun shadcn@latest add signup-01
These commands generated ready-to-use login and signup components that I customized for my application.
Login Route
src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { LoginForm } from "@/components/login-form";
export const Route = createFileRoute("/")({
component: LoginPage,
});
function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md">
<LoginForm />
</div>
</div>
);
}
Signup Route
src/routes/signup.tsx
import { createFileRoute } from "@tanstack/react-router";
import { SignupForm } from "@/components/signup-form";
export const Route = createFileRoute("/signup")({
component: SignupPage,
});
function SignupPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="w-full max-w-md">
<SignupForm />
</div>
</div>
);
}
Connecting Pages with TanStack Router Links
To navigate between Login and Signup pages without reloading the browser, I used TanStack Router's Link component.
Inside Signup Form
src/components/signup-form.tsx
Import Link:
import { Link } from "@tanstack/react-router";
Replace:
<FieldDescription className="px-6 text-center">
Already have an account? <a href="#">Sign in</a>
</FieldDescription>
with:
<FieldDescription className="px-6 text-center">
Already have an account?{" "}
<Link to="/">Sign in</Link>
</FieldDescription>
Inside Login Form
src/components/login-form.tsx
Replace:
<FieldDescription className="text-center">
Don't have an account?
<a href="#">Signup</a>
</FieldDescription>
with:
<FieldDescription className="text-center">
Don't have an account?
<Link to="/signup">Signup</Link>
</FieldDescription>
Form Handling with React Hook Form
For form state management and validation, I used React Hook Form.
Install it:
bun add react-hook-form
Import useForm:
import { useForm } from "react-hook-form";
Initialize the form:
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>();
Create a submit handler:
const onSubmit = (data: SignupFormData) => {
console.log(data);
// signup mutation here
};
Update the form:
<form onSubmit={handleSubmit(onSubmit)}>
Connect the inputs:
<Input
id="first-name"
type="text"
placeholder="John"
{...register("firstName", {
required: "First name is required",
})}
/>
<Input
id="last-name"
type="text"
placeholder="Doe"
{...register("lastName", {
required: "Last name is required",
})}
/>
<Input
id="email"
type="email"
placeholder="m@example.com"
{...register("email", {
required: "Email is required",
pattern: {
value: /^\S+@\S+$/i,
message: "Invalid email address",
},
})}
/>
<Input
id="password"
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message: "Password must be at least 8 characters",
},
})}
/>
Show validation errors using your existing FieldError component:
import { FieldError } from "@/components/ui/field";
Example:
<Field>
<FieldLabel htmlFor="first-name">First Name</FieldLabel>
<Input
id="first-name"
type="text"
placeholder="John"
{...register("firstName", {
required: "First name is required",
})}
/>
<FieldError errors={[errors.firstName]} />
</Field>
For email:
<FieldError errors={[errors.email]} />
For password:
<FieldError errors={[errors.password]} />
This gives you:
Form state management
Validation
Error messages
No re-renders on every keystroke
Easy integration later with React Query mutations:
Here's your LoginForm connected to React Hook Form in the same style as the signup form.
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Link } from "@tanstack/react-router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
type LoginFormData = {
email: string;
password: string;
};
export function LoginForm({
className,
...props
}: React.ComponentProps<"div">) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>();
const onSubmit = (data: LoginFormData) => {
console.log(data);
// loginMutation.mutate(data)
};
return (
<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader>
<CardTitle>Login to your account</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)}>
<FieldGroup>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
type="email"
placeholder="m@example.com"
{...register("email", {
required: "Email is required",
pattern: {
value: /^\S+@\S+\.\S+$/,
message: "Please enter a valid email",
},
})}
/>
<FieldError errors={[errors.email]} />
</Field>
<Field>
<div className="flex items-center">
<FieldLabel htmlFor="password">Password</FieldLabel>
<a
href="#"
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
>
Forgot your password?
</a>
</div>
<Input
id="password"
type="password"
{...register("password", {
required: "Password is required",
})}
/>
<FieldError errors={[errors.password]} />
</Field>
<Field>
<Button type="submit">Login</Button>
<Button variant="outline" type="button">
Login with Google
</Button>
<FieldDescription className="text-center">
Don't have an account?{" "}
<Link to="/signup">Sign up</Link>
</FieldDescription>
</Field>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
);
}
Step 5: Setting Up Axios and TanStack Query
For API communication, I used Axios along with TanStack Query.
A common misconception is that React Query replaces Axios. It doesn't.
Both solve different problems:
| Tool | Responsibility |
|---|---|
| Axios | Making HTTP requests |
| TanStack Query | Managing server state, caching, loading states, retries, mutations, and refetching |
The flow looks like this:
Component
↓
TanStack Query
(useQuery / useMutation)
↓
API Function
↓
Axios Instance
↓
Backend API
Creating the Axios Instance
First, create a reusable Axios instance.
src/api/axios.ts
import axios from "axios";
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
timeout: 10000,
});
export default api;
Why These Options?
baseURLprevents repeating the API URL in every request.withCredentialsallows cookies to be sent with requests (useful for refresh tokens).timeoutautomatically aborts requests that take too long.
Creating the Axios Instance
First, create a reusable Axios instance.
src/api/axios.ts
import axios from "axios";
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
withCredentials: true,
timeout: 10000,
});
export default api;
Why These Options?
baseURLprevents repeating the API URL in every request.withCredentialsallows cookies to be sent with requests (useful for refresh tokens).timeoutautomatically aborts requests that take too long.
Token Utilities
I stored the access token in localStorage.
src/utils/token.ts
export const getAccessToken = () =>
localStorage.getItem("accessToken");
export const setAccessToken = (token: string) =>
localStorage.setItem(
"accessToken",
token
);
export const clearTokens = () => {
localStorage.removeItem("accessToken");
};
Refresh Token API
When an access token expires, we'll request a new one using the refresh token.
src/api/auth.api.ts
import axios from "axios";
export const refreshToken = async () => {
return axios.post(
`${import.meta.env.VITE_API_URL}/v1/user/refresh`,
{},
{
withCredentials: true,
}
);
};
Axios Interceptors
One of the best features of Axios is interceptors.
Interceptors allow us to:
Attach tokens automatically.
Refresh expired access tokens.
Retry failed requests.
Centralize authentication logic.
Request Interceptor
Before every request, attach the access token.
api.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Now every authenticated request automatically includes:
Authorization: Bearer your_access_token
without manually adding headers everywhere.
Response Interceptor
When the backend returns:
401 Unauthorized
the interceptor attempts to refresh the token and retries the original request.
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const res = await refreshToken();
const newAccessToken = res.data.data.accessToken;
setAccessToken(newAccessToken);
api.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`;
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return api(originalRequest);
} catch (error) {
clearTokens();
return Promise.reject(error);
}
}
return Promise.reject(error);
},
);
Authentication Flow
Request
↓
Access Token Added
↓
Backend
↓
401 ?
↓
Yes
↓
Refresh Token API
↓
New Access Token
↓
Retry Original Request
This creates a seamless authentication experience because users remain logged in even when access tokens expire.
Installing TanStack Query
bun add @tanstack/react-query
Creating the Query Client
Create a centralized Query Client.
src/lib/react-query.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient =
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 1000 * 60 * 5,
},
},
});
What Do These Options Mean?
retry: 1
If a query fails, React Query retries it once before marking it as failed.
staleTime: 1000 * 60 * 5
Query data remains fresh for 5 minutes.
During that period, React Query serves data from cache instead of immediately making another API request.
Providing React Query to the Application
Wrap the application using QueryClientProvider.
src/main.tsx
import ReactDOM from "react-dom/client";
import {
QueryClientProvider,
} from "@tanstack/react-query";
import { queryClient } from "@/lib/react-query";
import "./api/axiosInterceptor";
ReactDOM.createRoot(
document.getElementById("root")!
).render(
<QueryClientProvider
client={queryClient}
>
<App />
</QueryClientProvider>
);
Notice that the Axios interceptor is imported once during application startup.
This ensures every API request automatically benefits from token handling and refresh logic.
At this point we have:
✅ Axios configured
✅ Automatic token injection
✅ Automatic token refresh
✅ Global React Query setup
✅ Query caching and retry support
In the next section, we'll create authentication APIs and connect our Login and Signup forms using React Query mutations.
Understanding React Query Concepts
At first, React Query may seem like just two hooks:
useQuery()
useMutation()
But it provides many powerful features that make server-state management much easier.
1. Query Keys
React Query identifies cached data using query keys.
useQuery({
queryKey: ["users"],
queryFn: getUsers,
});
useQuery({
queryKey: ["user", userId],
queryFn: () => getUser(userId),
});
This creates separate cache entries:
["users"]
["user", 1]
["user", 2]
Each user is cached independently.
2. Mutations
Mutations are used when modifying data on the server.
Common use cases:
Create (POST)
Update (PUT/PATCH)
Delete (DELETE)
const createUserMutation = useMutation({
mutationFn: createUser,
});
3. Loading and Error States
React Query automatically provides request status.
Queries
const {
data,
isLoading,
isError,
} = useQuery(...);
Mutations
const mutation = useMutation(...);
mutation.isPending;
mutation.isSuccess;
mutation.isError;
Example:
<Button disabled={mutation.isPending}>
{mutation.isPending
? "Creating..."
: "Create Account"}
</Button>
4. Query Invalidation
After creating or updating data, you often need fresh data.
queryClient.invalidateQueries({
queryKey: ["users"],
});
This marks the query as stale and triggers a refetch.
5. Conditional Queries
Sometimes a query should only run when a condition is true.
useQuery({
queryKey: ["profile"],
queryFn: getProfile,
enabled: isLoggedIn,
});
The query executes only when:
isLoggedIn === true
6. Dependent Queries
A query can depend on the result of another query.
const { data: user } = useQuery({
queryKey: ["user"],
queryFn: getCurrentUser,
});
const { data: transactions } = useQuery({
queryKey: ["transactions", user?.id],
queryFn: () => getTransactions(user!.id),
enabled: !!user,
});
The second query waits until the user data is available.
7. Manual Cache Updates
Instead of refetching data, you can update the cache directly.
queryClient.setQueryData(
["todos"],
(old) => [...old, newTodo]
);
This makes the UI update instantly.
8. Optimistic Updates
Optimistic updates show changes in the UI before the server responds.
const mutation = useMutation({
mutationFn: createTodo,
onMutate: async (newTodo) => {
queryClient.setQueryData(
["todos"],
(old) => [...old, newTodo]
);
},
});
The user sees the new item immediately, creating a faster experience.
9. Prefetching Data
React Query allows loading data before a page renders.
await queryClient.prefetchQuery({
queryKey: ["users"],
queryFn: getUsers,
});
This works especially well with TanStack Router loaders.
Organizing Authentication Logic
As the application started growing, I didn't want API calls and React Query logic mixed directly inside components. To keep things clean and scalable, I created a feature-based structure for authentication.
Folder Structure
src
│
├── features
│ └── auth
│ ├── api
│ │ └── auth.api.ts
│ │
│ └── hooks
│ ├── useSignupMutation.ts
│ └── useLoginMutation.ts
This keeps all authentication-related code in a single place.
Creating Authentication APIs
Inside:
src/features/auth/api/auth.api.ts
I created all authentication API functions.
Signup API
import api from "@/api/axios";
export type SignupPayload = {
firstName: string;
lastName: string;
email: string;
password: string;
};
export const signupUser = async (
data: SignupPayload
) => {
const response = await api.post(
"/v1/user/signup",
data
);
return response.data;
};
Login API
export type LoginPayload = {
email: string;
password: string;
};
export type LoginResponse = {
success: boolean;
message: string;
data: {
accessToken: string;
user: {
id: string;
firstName: string;
lastName: string;
email: string;
};
};
};
export const loginUser = async (
data: LoginPayload
): Promise<LoginResponse> => {
const response = await api.post<LoginResponse>(
"/v1/user/signin",
data
);
return response.data;
};
Creating React Query Mutation Hooks
Instead of calling APIs directly inside components, I wrapped them inside custom hooks.
Signup Mutation
src/features/auth/hooks/useSignupMutation.ts
import { useMutation } from "@tanstack/react-query";
import {
signupUser,
type SignupPayload,
} from "../api/auth.api";
export const useSignupMutation = () => {
return useMutation({
mutationFn: (data: SignupPayload) =>
signupUser(data),
});
};
Login Mutation
src/features/auth/hooks/useLoginMutation.ts
import { useMutation } from "@tanstack/react-query";
import { loginUser } from "../api/auth.api";
export const useLoginMutation = () => {
return useMutation({
mutationFn: loginUser,
});
};
This gives us access to:
mutation.mutate()
mutation.isPending
mutation.isSuccess
mutation.isError
Connecting Signup Form
Inside the Signup form component, I used the custom signup mutation hook.
const navigate = useNavigate();
const signupMutation =
useSignupMutation();
const onSubmit = (
data: SignupFormData
) => {
signupMutation.mutate(data, {
onSuccess: () => {
navigate({
to: "/",
});
},
});
};
Flow:
Submit Form
↓
signupMutation.mutate()
↓
POST /signup
↓
Success
↓
Redirect to Login Page
Connecting Login Form
For login, I wanted to:
Authenticate the user
Store the access token
Store user information in React Query cache
Redirect to Dashboard
const navigate = useNavigate();
const loginMutation =
useLoginMutation();
const onSubmit = (
data: LoginFormData
) => {
loginMutation.mutate(data, {
onSuccess: (response) => {
const {
accessToken,
user,
} = response.data;
setAccessToken(accessToken);
navigate({
to: "/dashboard",
});
},
onError: (error) => {
console.error(error);
},
});
};
At this point we have:
✅ Signup Page
✅ Login Page
✅ Form Validation with React Hook Form
✅ API Layer with Axios
✅ Automatic Token Refresh using Axios Interceptors
✅ React Query Mutations
In the next section, we'll build protected routes.



