Skip to main content

Command Palette

Search for a command to run...

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

Updated
16 min read
Building a Paytm-Style Payments Frontend with React, TanStack Router, React Query, and Shadcn UI
S

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.

  1. 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 screens

  • md: Tablets

  • lg: Laptops

  • xl: Desktops

  • 2xl: Large displays

Container

<div class="container mx-auto px-4">
  • container creates a responsive container

  • mx-auto centers it horizontally

  • px-4 adds 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&apos;t have an account? 
  <a href="#">Signup</a>
</FieldDescription>

with:

<FieldDescription className="text-center">
  Don&apos;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&apos;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?

  • baseURL prevents repeating the API URL in every request.

  • withCredentials allows cookies to be sent with requests (useful for refresh tokens).

  • timeout automatically 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?

  • baseURL prevents repeating the API URL in every request.

  • withCredentials allows cookies to be sent with requests (useful for refresh tokens).

  • timeout automatically 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:

  1. Authenticate the user

  2. Store the access token

  3. Store user information in React Query cache

  4. 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.

uilding a Paytm-Style Payments Frontend in React