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
39 min readView as Markdown
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.

Prerequisites

Before building the frontend application, I already created the backend for this project.

The backend is a Paytm-style payment system built with Bun, TypeScript, Express, MongoDB, JWT authentication, and MongoDB transactions.

If you'd like to understand how the backend works or build it yourself, you can read the complete backend tutorial here:

Backend Tutorial:
https://blog.realdev.club/building-a-paytm-style-payments-backend-with-bun-typescript-express-mongodb-transaction


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.


Step 6: Protecting Routes with TanStack Router

Now that authentication is working, we need to prevent unauthorized users from accessing protected pages such as the Dashboard.

TanStack Router provides an excellent authentication pattern through route context and beforeLoad(). I followed the approach recommended in the official documentation:

TanStack Router Authentication Guide

The goal is simple:

  • Unauthenticated users should only access Login and Signup pages.

  • Authenticated users should access protected pages such as Dashboard.

  • Logged-in users should not be able to visit the Login page again.

To achieve this, we'll create a central authentication context and make it available to every route.


Creating an Authentication Context

Instead of checking authentication inside every page, I created a single Auth Context.

src/auth.tsx
import React, { createContext, useContext } from "react";
import { useQuery } from "@tanstack/react-query";
import { getMe } from "@/features/auth/api/auth.api";
import { Spinner } from "@/components/ui/spinner";

export interface User {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
}

interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
}

const AuthContext = createContext<AuthState | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const {
    data: user,
    isLoading,
    isError,
  } = useQuery({
    queryKey: ["me"],
    queryFn: getMe,
    retry: false,
  });

  const isAuthenticated = !!user && !isError;

  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <Spinner />
      </div>
    );
  }

  return (
    <AuthContext.Provider
      value={{
        user: user ?? null,
        isAuthenticated,
        isLoading,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);

  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }

  return context;
}

The responsibility of this provider is simple:

  1. Fetch the current user using getMe()

  2. Determine whether the user is authenticated

  3. Make authentication state available throughout the application


Passing Authentication to TanStack Router

Next, we need TanStack Router to access this authentication state.

Configure Router Context

Update src/routes/__root.tsx

import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import type { User } from "@/auth";

interface RouterAuth {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
}

interface RouterContext {
  auth: RouterAuth;
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: () => (
    <>
      <Outlet />
      <TanStackRouterDevtools />
    </>
  ),
});

Now every route understands that router context contains:

context.auth

Configure the Router

src/router.ts

import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

export const router = createRouter({
  routeTree,
  context: {
    auth: undefined!,
  },
});

declare module "@tanstack/react-router" {
  interface Register {
    router: typeof router;
  }
}

Notice that we are only defining the shape of the context here.

The actual value will be provided later.


Connect AuthProvider and Router

src/App.tsx

import { RouterProvider } from "@tanstack/react-router";
import { AuthProvider, useAuth } from "./auth";
import { router } from "./router";

function InnerApp() {
  const auth = useAuth();

  return (
    <RouterProvider
      router={router}
      context={{
        auth,
      }}
    />
  );
}

export default function App() {
  return (
    <AuthProvider>
      <InnerApp />
    </AuthProvider>
  );
}

Creating Protected Routes

Instead of protecting every route individually, I created a protected layout route.

routes
│
├── _authenticated.tsx
│
└── _authenticated
    └── dashboard.tsx

Everything inside _authenticated automatically becomes protected.


Protected Layout Route

src/routes/_authenticated.tsx

import { createFileRoute, redirect, Outlet } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated")({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: "/",
      });
    }
  },

  component: () => <Outlet />,
});

Before any child route renders, TanStack Router executes:

beforeLoad()
      ↓
Authenticated?
      ↓
Yes → Continue
No  → Redirect("/")

Why Do We Need Another Outlet?

We already have an Outlet inside __root.tsx, so why do we need another one?

The route hierarchy looks like:

__root.tsx
    ↓
Outlet
    ↓
_authenticated.tsx
    ↓
Outlet
    ↓
dashboard.tsx

Every layout route needs its own Outlet to render child routes.

Without the second outlet:

__root
   ↓
_authenticated

the dashboard would never render.


Dashboard Route

src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/dashboard")({
  component: Dashboard,
});

function Dashboard() {
  const { auth } = Route.useRouteContext();

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>

      <p>
        Welcome {auth.user?.firstName} {auth.user?.lastName}
      </p>

      <p>{auth.user?.email}</p>
    </div>
  );
}

Because authentication is stored inside router context, the dashboard can directly access:

auth.user

without making another API request.


Redirecting Logged-In Users Away From Login

We also don't want authenticated users visiting the login page.

src/routes/index.tsx

export const Route = createFileRoute("/")({
  beforeLoad: ({ context }) => {
    if (context.auth.isAuthenticated) {
      throw redirect({
        to: "/dashboard",
      });
    }
  },

  component: LoginPage,
});

Flow:

User Visits "/"
        ↓
Already Logged In?
        ↓
Yes
        ↓
Redirect "/dashboard"

Notice that we're checking:

context.auth.isAuthenticated

instead of:

context.auth.user

This keeps route logic independent of how authentication is implemented internally.


The Complete Authentication Lifecycle

Let's walk through what happens when the application starts.

Step 1: App renders

export default function App() {
  return (
    <AuthProvider>
      <InnerApp />
    </AuthProvider>
  );
}

At this moment:

AuthProvider mounts
↓
useQuery(["me"]) runs
↓
getMe() API is called

Step 2: AuthProvider executes

const {
  data: user,
  isLoading,
  isError,
} = useQuery({
  queryKey: ["me"],
  queryFn: getMe,
  retry: false,
});

Initially:

user = undefined
isLoading = true
isError = false

because the API request is still in progress.


Step 3: Loading screen

Because:

if (isLoading) {
  return <Loading />;
}

React renders:

Loading...

and InnerApp does not render yet.

This is important.

At this point:

RouterProvider is NOT mounted
Routes are NOT running
beforeLoad is NOT running

Step 4: getMe finishes

Suppose the backend returns:

{
  "data": {
    "user": {
      "id": "1",
      "firstName": "Shubham",
      "lastName": "Singh",
      "email": "test@test.com"
    }
  }
}

Then:

user = {
  id: "1",
  firstName: "Shubham",
  ...
}

isLoading = false
isError = false

Step 5: isAuthenticated

You calculate:

const isAuthenticated = !!user && !isError;

Since:

!!user === true
!isError === true

Result:

isAuthenticated = true

Step 6: Context is created

<AuthContext.Provider
  value={{
    user,
    isAuthenticated,
    isLoading,
  }}
>

Now the context contains:

{
  user: {...},
  isAuthenticated: true,
  isLoading: false
}

Step 7: InnerApp renders

function InnerApp() {
  const auth = useAuth();

  return (
    <RouterProvider
      router={router}
      context={{
        auth,
      }}
    />
  );
}

useAuth() returns:

{
  user: {...},
  isAuthenticated: true,
  isLoading: false
}

Then:

context={{
  auth,
}}

passes it into TanStack Router.


Step 8: Router Context Becomes Available Everywhere

Because the router receives the authentication state through context:

<RouterProvider
  context={{
    auth,
  }}
/>

every route now has access to:

context.auth

inside:

  • beforeLoad

  • loader

  • Route components

This is the bridge between React Context and TanStack Router.


Step 9: Public Route Protection

Consider the login page:

export const Route = createFileRoute("/")({
  beforeLoad: ({ context }) => {
    if (context.auth.isAuthenticated) {
      throw redirect({
        to: "/dashboard",
      });
    }
  },
});

Current value:

context.auth = {
  user: {...},
  isAuthenticated: true,
  isLoading: false
}

Since:

context.auth.isAuthenticated === true

TanStack Router immediately redirects:

/
 ↓
Already authenticated
 ↓
/dashboard

This prevents logged-in users from seeing the login page again.


Step 10: Protected Route Check

Now suppose the user visits:

/dashboard

This route actually matches:

/_authenticated/dashboard

Before the dashboard renders, TanStack Router first executes the parent route:

/_authenticated

which contains:

beforeLoad: ({ context }) => {
  if (!context.auth.isAuthenticated) {
    throw redirect({
      to: "/",
    });
  }
}

Current value:

context.auth.isAuthenticated === true

Therefore:

!true

becomes:

false

No redirect occurs and the dashboard is rendered.


Complete Authentication Flow

                  Application Starts
                          ↓
                  AuthProvider Mounts
                          ↓
                    useQuery(["me"])
                          ↓
                        getMe()
                          ↓
                     User Returned?
                          ↓
 ┌───────────────┴───────────────┐
 │                                                 │
Yes                                                No
 │                                                 │
Authenticated                                Unauthenticated
 │                                                 │
isAuthenticated=true                      isAuthenticated=false
 │                                                 │
AuthContext                                    AuthContext
 │                                                 │
RouterProvider                                RouterProvider
 │                                                 │
context.auth                                   context.auth
 │                                                 │
beforeLoad()                                   beforeLoad()
 │                                                 │
Dashboard                                      Redirect("/")

Updating the Login Flow

const onSubmit = (data: LoginFormData) => {
  loginMutation.mutate(data, {
    onSuccess: (response) => {
      const {
        accessToken,
        user,
      } = response.data;

      setAccessToken(accessToken);

      navigate({
        to: "/dashboard",
      });
    },
  });
};

is no longer enough after implementing route protection with AuthProvider.

The reason is that AuthProvider relies on:

useQuery({
  queryKey: ["me"],
  queryFn: getMe,
})

to determine authentication status.

After login succeeds, React Query still has the old cached result for ["me"].

So even though the access token is stored, the authentication state may not update immediately.


Updated Login Handler

const onSubmit = (data: LoginFormData) => {
  loginMutation.mutate(data, {
    onSuccess: async (res) => {
      setAccessToken(res.data.accessToken);

      await queryClient.invalidateQueries({
        queryKey: ["me"],
      });

      navigate({
        to: "/dashboard",
        replace: true,
      });
    },
  });
};

What Does invalidateQueries Do?

await queryClient.invalidateQueries({
  queryKey: ["me"],
});

This tells React Query:

The cached user information is now stale.
Please fetch it again.

React Query then executes:

getMe()

again.

The new request now includes the fresh access token through our Axios interceptor.

Login Success
      ↓
Access Token Stored
      ↓
invalidateQueries(["me"])
      ↓
getMe()
      ↓
User Returned
      ↓
AuthProvider Updates
      ↓
isAuthenticated = true
      ↓
Navigate("/dashboard")

Final Login Flow

User Submits Login Form
        ↓
POST /signin
        ↓
Access Token Received
        ↓
Store Access Token
        ↓
invalidateQueries(["me"])
        ↓
getMe()
        ↓
AuthProvider Updates
        ↓
isAuthenticated = true
        ↓
Navigate("/dashboard")
        ↓
Protected Route Passes
        ↓
Dashboard Renders

Step7: Building a Dashboard Layout with Shadcn Sidebar and Tanstack Router

After implementing authentication and protected routes, the next step was creating a dashboard layout that could be shared across all authenticated pages.

Since the application will eventually contain multiple pages such as Dashboard, Check Balance, Transfer Money, and Transactions, I wanted a reusable layout with a sidebar instead of duplicating navigation on every page.

Installing the Sidebar Component

I started by exploring the Shadcn UI sidebar documentation:

Shadcn Sidebar Documentation

Then installed the sidebar block:

bunx --bun shadcn@latest add sidebar

This generated the core sidebar components and supporting utilities.


Using a Layout Route

One of the biggest advantages of TanStack Router is route layouts.

Instead of placing the sidebar inside every page component, I created a layout route using _authenticated.tsx.

import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/app-sidebar";

export const Route = createFileRoute("/_authenticated")({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: "/",
      });
    }
  },
  component: AuthenticatedLayout,
});

function AuthenticatedLayout() {
  return (
    <SidebarProvider>
      <AppSidebar />

      <main className="flex-1">
        <SidebarTrigger />

        <Outlet />
      </main>
    </SidebarProvider>
  );
}

The important part here is the second Outlet.

Our route hierarchy now looks like:

__root.tsx
    ↓
_authenticated.tsx
    ↓
Dashboard
CheckBalance

Whenever a child route renders, it appears inside the layout's Outlet.

This means the sidebar is rendered once and shared across all authenticated pages.


Creating Dashboard Pages

Next, I created dedicated route files for each page.

routes
│
├── _authenticated.tsx
│
└── _authenticated
    ├── Dashboard.tsx
    └── CheckBalance.tsx

Dashboard Route:

import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/Dashboard")({
  component: DashboardPage,
});

function DashboardPage() {
  return <div>Dashboard Page</div>;
}

Check Balance Route:

import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/CheckBalance")({
  component: CheckBalancePage,
});

function CheckBalancePage() {
  return <div>Check Balance Page</div>;
}

TanStack Router automatically generates route definitions from these files.


Creating the Sidebar Component

Next, I created an AppSidebar component responsible for rendering navigation links and user information.

src/components/app-sidebar
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarHeader,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar";

import { NavUser } from "@/components/nav-user";
import { IconChartBar, IconDashboard } from "@tabler/icons-react";
import { NavMain } from "@/components/nav-main";

export function AppSidebar() {
  const data = {
    user: {
      name: "shadcn",
      email: "m@example.com",
      avatar: "/avatars/shadcn.jpg",
    },
    navMain: [
      {
        title: "Dashboard",
        url: "/dashboard",
        icon: IconDashboard,
      },
      {
        title: "Check Balance",
        url: "/CheckBalance",
        icon: IconChartBar,
      },
    ],
  };

  return (
    <Sidebar>
      <SidebarHeader>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton
              asChild
              className="data-[slot=sidebar-menu-button]:p-1.5!"
            >
              <a href="#">
                <span className="text-base font-semibold">Payments</span>
              </a>
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarHeader>
      <SidebarContent>
        <NavMain items={data.navMain} />
      </SidebarContent>
      <SidebarFooter>
        <NavUser user={data.user} />
      </SidebarFooter>
    </Sidebar>
  );
}

The navigation data is centralized in a simple configuration object:

  navMain: [
    {
      title: "Dashboard",
      url: "/Dashboard",
      icon: IconDashboard,
    },
    {
      title: "Check Balance",
      url: "/CheckBalance",
      icon: IconChartBar,
    },
  ],
};

This makes it easy to add additional pages later.

For example:

{
  title: "Transfer Money",
  url: "/TransferMoney",
  icon: IconTransfer,
}

without modifying the sidebar implementation.


The AppSidebar component acts as the main navigation container for all authenticated pages.

To keep things maintainable, I separated the sidebar into three distinct sections:

  • SidebarHeader → Displays application branding.

  • SidebarContent → Contains the main navigation links.

  • SidebarFooter → Displays the logged-in user's information and actions.

This structure makes the sidebar easier to extend as the application grows.

The navigation links themselves are not rendered directly inside AppSidebar. Instead, they are passed to a dedicated NavMain component.

<SidebarContent>
  <NavMain items={data.navMain} />
</SidebarContent>

This keeps the sidebar focused on layout while allowing navigation logic to live in a reusable component.

Creating Reusable Navigation

To render navigation links, I created a NavMain component.

src/components/nav-main.tsx
import type { Icon } from "@tabler/icons-react";
import { Link } from "@tanstack/react-router";

import {
  SidebarGroup,
  SidebarGroupContent,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar";

export function NavMain({
  items,
}: {
  items: {
    title: string;
    url: string;
    icon?: Icon;
  }[];
}) {
  return (
    <SidebarGroup>
      <SidebarGroupContent>
        <SidebarMenu>
          {items.map((item) => (
            <SidebarMenuItem key={item.title}>
              <SidebarMenuButton asChild>
                <Link to={item.url}>
                  {item.icon && <item.icon />}
                  <span>{item.title}</span>
                </Link>
              </SidebarMenuButton>
            </SidebarMenuItem>
          ))}
        </SidebarMenu>
      </SidebarGroupContent>
    </SidebarGroup>
  );
}

Adding a User Dropdown

Most dashboard applications provide quick access to profile-related actions directly from the sidebar.

To implement this, I installed additional Shadcn UI components:

bunx --bun shadcn@latest add dropdown-menu
bunx --bun shadcn@latest add avatar

I also installed Tabler Icons for consistent iconography throughout the application:

bun add @tabler/icons-react

Next, I created a dedicated NavUser component that displays:

src/components/nav-user.tsx
import {
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  useSidebar,
} from "./ui/sidebar";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  IconDotsVertical,
  IconLogout,
  IconUserCircle,
} from "@tabler/icons-react";

export function NavUser({
  user,
}: {
  user: {
    name: string;
    email: string;
    avatar: string;
  };
}) {
  const { isMobile } = useSidebar();

  return (
    <SidebarMenu>
      <SidebarMenuItem>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <SidebarMenuButton
              size="lg"
              className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
            >
              <Avatar className="h-8 w-8 rounded-lg grayscale">
                <AvatarImage src={user.avatar} alt={user.name} />
                <AvatarFallback className="rounded-lg">CN</AvatarFallback>
              </Avatar>
              <div className="grid flex-1 text-left text-sm leading-tight">
                <span className="truncate font-medium">{user.name}</span>
                <span className="truncate text-xs text-muted-foreground">
                  {user.email}
                </span>
              </div>
              <IconDotsVertical className="ml-auto size-4" />
            </SidebarMenuButton>
          </DropdownMenuTrigger>
          <DropdownMenuContent
            className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
            side={isMobile ? "bottom" : "right"}
            align="end"
            sideOffset={4}
          >
            <DropdownMenuLabel className="p-0 font-normal">
              <div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
                <Avatar className="h-8 w-8 rounded-lg">
                  <AvatarImage src={user.avatar} alt={user.name} />
                  <AvatarFallback className="rounded-lg">CN</AvatarFallback>
                </Avatar>
                <div className="grid flex-1 text-left text-sm leading-tight">
                  <span className="truncate font-medium">{user.name}</span>
                  <span className="truncate text-xs text-muted-foreground">
                    {user.email}
                  </span>
                </div>
              </div>
            </DropdownMenuLabel>
            <DropdownMenuSeparator />
            <DropdownMenuGroup>
              <DropdownMenuItem>
                <IconUserCircle />
                Profile
              </DropdownMenuItem>
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            <DropdownMenuItem>
              <IconLogout />
              Log out
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      </SidebarMenuItem>
    </SidebarMenu>
  );
}

This component is rendered inside the sidebar footer:

<SidebarFooter>
  <NavUser user={data.user} />
</SidebarFooter>

Separating user-related functionality into its own component keeps the sidebar clean and makes future enhancements—such as connecting it to real authentication data—much easier.

The final result is a dashboard layout where navigation lives on the left, user actions are grouped at the bottom, and TanStack Router renders the selected page inside the shared layout through the Outlet.


Final Route Structure

After implementing the sidebar layout, the application structure became:

routes
│
├── __root.tsx
│
├── index.tsx
├── signup.tsx
│
├── _authenticated.tsx
│
└── _authenticated
    ├── Dashboard.tsx
    └── CheckBalance.tsx

Navigation flow:

      Login
        ↓
Dashboard Layout
        ↓
 ┌──────────
 │  Sidebar      │
 │               │
 │ Dashboard     │
 │ Check Balance │
 └──────────
         ↓
      Outlet
         ↓
   Selected Page

Step8: Building the Members Dashboard

With authentication in place, the next step was creating a dashboard where users can view and interact with other members of the platform.

Fetching All Users

On the backend, I created a protected GET /getAllUsers endpoint that returns all users except the currently logged-in user. This endpoint is protected by authMiddleware, ensuring that only authenticated users can access the data.

On the frontend, I created a helper function to fetch users from the API:

export const getAllUsers = async () => {
  const response = await api.get("/v1/user/getAllUsers");

  return response.data.data.users;
};

Creating the Members Table

I used Shadcn UI components to create a clean dashboard table.

src/components/members-table.tsx
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
  CardDescription,
} from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { CreditCard } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getAllUsers } from "@/features/auth/api/auth.api";
import { Spinner } from "./ui/spinner";
import { useState } from "react";
import { SendMoneyDialog } from "@/components/send-money-dialog";

export function MembersTable() {
  const [selectedUser, setSelectedUser] = useState<{
    _id: string;
    firstName: string;
    lastName: string;
    email: string;
  } | null>(null);

  const [isDialogOpen, setIsDialogOpen] = useState(false);
  const { data: users = [], isLoading } = useQuery({
    queryKey: ["users"],
    queryFn: getAllUsers,
  });

  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <Spinner />
      </div>
    );
  }

  return (
    <>
      <Card className="flex h-full flex-col">
        <CardHeader>
          <CardTitle>Members</CardTitle>
          <CardDescription>{users.length} people</CardDescription>
        </CardHeader>

        <CardContent className="flex-1 overflow-auto p-0">
          <div className="w-full">
            <table className="w-full">
              <thead className="border-b text-muted-foreground">
                <tr>
                  <th className="px-6 py-3 text-left text-sm font-medium">
                    Name
                  </th>
                  <th className="px-6 py-3 text-left text-sm font-medium">
                    Email
                  </th>
                  <th className="px-6 py-3 text-right text-sm font-medium">
                    Action
                  </th>
                </tr>
              </thead>

              <tbody>
                {users.map(
                  (user: {
                    _id: string;
                    firstName: string;
                    lastName: string;
                    email: string;
                  }) => (
                    <tr
                      key={user._id}
                      className="border-b transition-colors hover:bg-muted/50"
                    >
                      <td className="px-6 py-4">
                        <div className="flex items-center gap-3">
                          <Avatar className="h-9 w-9">
                            <AvatarFallback>
                              {`\({user.firstName[0]}\){user.lastName[0]}`}
                            </AvatarFallback>
                          </Avatar>

                          <span className="font-medium">
                            {user.firstName} {user.lastName}
                          </span>
                        </div>
                      </td>

                      <td className="px-6 py-4 text-muted-foreground">
                        {user.email}
                      </td>

                      <td className="px-6 py-4 text-right">
                        <Button
                          variant="outline"
                          size="sm"
                          onClick={() => {
                            setSelectedUser(user);
                            setIsDialogOpen(true);
                          }}
                        >
                          <CreditCard className="mr-2 h-4 w-4" />
                          Pay
                        </Button>
                      </td>
                    </tr>
                  ),
                )}
              </tbody>
            </table>
          </div>
        </CardContent>
      </Card>
      <SendMoneyDialog
        open={isDialogOpen}
        onOpenChange={setIsDialogOpen}
        user={selectedUser}
      />
    </>
  );
}

Fetching All Members with TanStack Query

To manage server state efficiently, I used TanStack Query's useQuery hook.

const { data: users = [], isLoading } = useQuery({
  queryKey: ["users"],
  queryFn: getAllUsers,
});

Finally, I updated the Dashboard route to render the MembersTable component.

routes/_authenticated/Dashboard.tsx
import { MembersTable } from "@/components/members-table";
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/_authenticated/Dashboard")({
  component: DashboardPage,
});

function DashboardPage() {
  return (
    <div className="h-[calc(100vh-5rem)] p-6">
      <MembersTable />
    </div>
  );
}

Result

At this point, users can:

  • View all members except themselves

  • Open a payment dialog for any member

  • Prepare for money transfers directly from the dashboard


Step 9: Implementing Money Transfer Functionality

Now that users can view all members from the dashboard, the next step is allowing them to transfer money to another user.

To keep the dashboard clean and reusable, I created a separate SendMoneyDialog component that opens whenever the user clicks the Pay button.


Creating the Transfer Dialog

The dialog displays the selected user's information and provides an input field where the sender can enter the amount they want to transfer.

src/components/send-money-dialog.tsx
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Send } from "lucide-react";
import { useState } from "react";
import { useTransferMoney } from "@/features/transferMoney/hooks/useTransferMoney";

type User = {
  _id: string;
  firstName: string;
  lastName: string;
  email: string;
};

type Props = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  user: User | null;
};

export function SendMoneyDialog({ open, onOpenChange, user }: Props) {
  const [amount, setAmount] = useState("");

  const transferMutation = useTransferMoney();

  if (!user) return null;

  const handleSendMoney = async () => {
    await transferMutation.mutateAsync({
      to: user._id,
      amount: Number(amount),
    });

    onOpenChange(false);
    setAmount("");
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Send payment</DialogTitle>
        </DialogHeader>

        {/* User details and amount input */}
      </DialogContent>
    </Dialog>
  );
}

Creating the Transfer API

Next, I created an API helper that sends the transfer request to the backend.

src/features/transferMoney/api/account.api.ts
import api from "@/api/axios";

export const transferMoney = (data: {
  to: string;
  amount: number;
}) => {
  return api.post("/v1/account/transfer", data);
};

Creating a Custom Mutation Hook

Since transferring money modifies server-side data, I used TanStack Query's useMutation hook.

src/features/transferMoney/hooks/useTransferMoney.ts
import { useMutation } from "@tanstack/react-query";
import { transferMoney } from "../api/account.api";

export const useTransferMoney = () => {
  return useMutation({
    mutationFn: transferMoney,
  });
};

Triggering the Transfer

When the user clicks the Send Payment button, the dialog calls the mutation and sends the recipient ID and amount to the backend.

const handleSendMoney = async () => {
  await transferMutation.mutateAsync({
    to: user._id,
    amount: Number(amount),
  });

  onOpenChange(false);
  setAmount("");
};

Result

At this stage, users can:

  • Open a payment dialog directly from the dashboard

  • Enter an amount to transfer

  • Send money to another user

  • Trigger a backend transaction securely

In the next step, we'll implement balance checking and display the user's current wallet balance inside the application.


Step 10: Implementing Balance Checking

After implementing money transfers, I wanted users to be able to check their current wallet balance.

To achieve this, I created a dedicated Check Balance page that fetches and displays the logged-in user's account balance.


Creating the Balance API

First, I created a helper function to fetch the user's balance from the backend.

export const checkBalance = async () => {
  const response = await api.get("/v1/account/checkBalance");

  return response.data.data;
};

Creating the Check Balance Route

Next, I created a separate route for checking account balance.

routes/_authenticated/CheckBalance.tsx
import { createFileRoute } from "@tanstack/react-router";
import CheckBalance from "@/components/check-balance";

export const Route = createFileRoute("/_authenticated/CheckBalance")({
  component: CheckBalancePage,
});

function CheckBalancePage() {
  return <CheckBalance />;
}

Building the Check Balance Component

I created a reusable CheckBalance component that displays the logged-in user's information along with a hidden balance section.

src/components/check-balance.tsx
"use client";

import { useState } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Eye, EyeOff, Loader2, Lock, Clock } from "lucide-react";
import { useRouter } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { checkBalance } from "@/features/transferMoney/api/account.api";

type User = {
  firstName: string;
  lastName: string;
};

export default function CheckBalance() {
  const router = useRouter();
  const user: User | null = router?.options?.context?.auth?.user ?? null;
  const [visible, setVisible] = useState(false);

  const { data, isFetching, refetch } = useQuery({
    queryKey: ["balance"],
    queryFn: checkBalance,
    enabled: false, // don't fetch on mount
  });

  const handleReveal = async () => {
    await refetch();
    setVisible(true);
  };

  const handleHide = () => {
    setVisible(false);
  };

  return (
    <div className="min-h-screen bg-muted/40 flex items-center justify-center p-4">
      <Card className="w-full max-w-sm shadow-sm">
        <CardHeader className="flex flex-col items-center text-center pb-0 pt-6">
          <Avatar className="mb-3 h-14 w-14">
            <AvatarFallback className="bg-blue-50 text-base font-medium text-blue-800">
              {`\({user?.firstName?.charAt(0)}\){user?.lastName?.charAt(0)}`}
            </AvatarFallback>
          </Avatar>

          <p className="text-sm font-medium text-foreground">{`\({user?.firstName} \){user?.lastName}`}</p>
        </CardHeader>

        <CardContent className="pt-6">
          <Separator className="mb-6" />

          <p className="text-[11px] uppercase tracking-widest text-muted-foreground text-center mb-3">
            Account balance
          </p>

          {/* Hidden state */}
          {!visible && (
            <div className="text-center">
              <div className="flex items-center justify-center gap-1.5 mb-2 h-12">
                {Array.from({ length: 8 }).map((_, i) => (
                  <span
                    key={i}
                    className="w-2 h-2 rounded-full bg-muted-foreground/30"
                  />
                ))}
              </div>
              <div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground mb-6">
                <Lock className="h-3 w-3" />
                Balance hidden
              </div>
              <Button
                className="w-full"
                onClick={handleReveal}
                disabled={isFetching}
              >
                {isFetching ? (
                  <>
                    <Loader2 className="h-4 w-4 animate-spin" />
                    Checking...
                  </>
                ) : (
                  <>
                    <Eye className="h-4 w-4" />
                    Check balance
                  </>
                )}
              </Button>
            </div>
          )}

          {/* Revealed state */}
          {visible && (
            <div className="text-center animate-in fade-in slide-in-from-bottom-1 duration-300">
              <div className="text-4xl font-medium tracking-tight text-foreground mb-1">
                ₹ {data?.balance?.toLocaleString()}
              </div>
              <div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground mb-6">
                <Clock className="h-3 w-3" />
                Updated just now
              </div>
              <Button variant="outline" className="w-full" onClick={handleHide}>
                <EyeOff className="h-4 w-4" />
                Hide balance
              </Button>
            </div>
          )}
        </CardContent>
      </Card>
    </div>
  );
}

Since checking balance is a read operation, I used TanStack Query's useQuery hook.

However, I didn't want the request to run immediately when the page loads, so I disabled automatic fetching using enabled: false.

const { data, isFetching, refetch } = useQuery({
  queryKey: ["balance"],
  queryFn: checkBalance,
  enabled: false,
});

This allows the query to run only when explicitly triggered.

Triggering the Request

When the user clicks the Check Balance button, I manually trigger the query using refetch() and reveal the balance card.

const handleReveal = async () => {
  await refetch();
  setVisible(true);
};

Result

At this point, users can:

  • Check their current wallet balance

  • Fetch balance only when requested

  • Hide and reveal balance whenever needed


Final Thoughts

In this tutorial, we built a complete Paytm-style wallet application using modern React and Node.js technologies.

Starting from project setup, we implemented:

  • User registration and login

  • JWT-based authentication

  • Protected routes with TanStack Router

  • Authentication state management with TanStack Query

  • Dashboard with all registered members

  • Money transfer functionality

  • Real-time balance checking

  • Reusable UI components with Shadcn UI

  • Backend APIs using Express and MongoDB

Source Code

The complete source code for this project is available on GitHub:

https://github.com/shubhamsinghbundela/payment-frontend-react-ts

Feel free to explore the code, fork the repository, and build upon it for your own learning.

Thanks for reading. I hope this tutorial helped you understand how to build a full-stack payment application using React, TypeScript, TanStack Router, TanStack Query, Express, MongoDB, and Shadcn UI.

Happy coding! 🎉