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

### 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](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**

```plaintext
bun create vite
```

Select:

```plaintext
React
TypeScript
```

Install dependencies:

```plaintext
bun install
```

Run the development server:

```plaintext
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](https://tanstack.com/router/latest/docs/framework/react/installation/manual?utm_source=chatgpt.com)

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:

```plaintext
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/login" element={<Login />} />
```

you create route files:

```plaintext
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.

```plaintext
routes/
└── __root.tsx
```

All pages are rendered inside this route.

Think of it like:

```javascript
function App() {
  return <Outlet />;
}
```

`Outlet` is the placeholder where child routes render.

Example:

```javascript
function RootLayout() {
  return (
    <>
      <Navbar />
      <Outlet />
      <TanStackRouterDevtools />
    </>
  );
}
```

If a user visits:

```plaintext
/dashboard
```

TanStack Router renders:

```plaintext
<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:

*   [Shadcn UI Installation Guide for Vite](https://ui.shadcn.com/docs/installation/vite#existing-project)
    

* * *

**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:

```plaintext
<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:

```plaintext
<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

```plaintext
<div class="bg-blue-500 text-white">
```

Spacing

```plaintext
<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

```plaintext
w-1/2
```

```plaintext
width: 50%;
```

Typography

```plaintext
text-lg font-bold text-center
```

Equivalent CSS:

```plaintext
font-size: 18px;
font-weight: bold;
text-align: center;
```

Borders & Shadows

```plaintext
<div class="border rounded shadow-lg">
```

Flexbox

```plaintext
<div class="flex items-center justify-between">
```

Grid

```plaintext
<div class="grid grid-cols-3 gap-4">
```

### Responsive Design

One of Tailwind's strongest features is responsive utilities.

```plaintext
<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**

```plaintext
<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:

```plaintext
theme: {
  colors: {
    primary: "#3490dc",
  },
}
```

Usage:

```plaintext
<div class="bg-primary">
```

**Reusing Styles with @apply**

Instead of repeating utilities:

```plaintext
bg-blue-500 text-white px-4 py-2 rounded
```

You can create reusable classes:

```plaintext
.btn {
  @apply bg-blue-500 text-white px-4 py-2 rounded;
}
```

Then use:

```plaintext
<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.

```plaintext
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**

```js
src/routes/index.tsx
```

```plaintext
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**

```js
src/routes/signup.tsx
```

```plaintext
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**

```js
src/components/signup-form.tsx
```

Import `Link`:

```javascript
import { Link } from "@tanstack/react-router";
```

Replace:

```javascript
<FieldDescription className="px-6 text-center">
  Already have an account? <a href="#">Sign in</a>
</FieldDescription>
```

with:

```javascript
<FieldDescription className="px-6 text-center">
  Already have an account?{" "}
  <Link to="/">Sign in</Link>
</FieldDescription>
```

**Inside Login Form**

```js
src/components/login-form.tsx
```

Replace:

```javascript
<FieldDescription className="text-center">
  Don&apos;t have an account? 
  <a href="#">Signup</a>
</FieldDescription>
```

with:

```javascript
<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:

```plaintext
bun add react-hook-form
```

Import `useForm`:

```typescript
import { useForm } from "react-hook-form";
```

Initialize the form:

```javascript
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<LoginFormData>();
```

Create a submit handler:

```typescript
const onSubmit = (data: SignupFormData) => {
  console.log(data);

  // signup mutation here
};
```

Update the form:

```typescript
<form onSubmit={handleSubmit(onSubmit)}>
```

Connect the inputs:

```typescript
<Input
  id="first-name"
  type="text"
  placeholder="John"
  {...register("firstName", {
    required: "First name is required",
  })}
/>
```

```javascript
<Input
  id="last-name"
  type="text"
  placeholder="Doe"
  {...register("lastName", {
    required: "Last name is required",
  })}
/>
```

```javascript
<Input
  id="email"
  type="email"
  placeholder="m@example.com"
  {...register("email", {
    required: "Email is required",
    pattern: {
      value: /^\S+@\S+$/i,
      message: "Invalid email address",
    },
  })}
/>
```

```javascript
<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:

```javascript
import { FieldError } from "@/components/ui/field";
```

Example:

```typescript
<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:

```javascript
<FieldError errors={[errors.email]} />
```

For password:

```typescript
<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.

```js
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:

```plaintext
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**

```plaintext
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.

```js
src/api/axios.ts
```

```plaintext
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**

```plaintext
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**

```plaintext
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.

```js
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:

```plaintext
401 Unauthorized
```

the interceptor attempts to refresh the token and retries the original request.

```js
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**

```plaintext
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

```plaintext
bun add @tanstack/react-query
```

* * *

**Creating the Query Client**

Create a centralized Query Client.

```js
src/lib/react-query.ts
```

```javascript
import { QueryClient } from "@tanstack/react-query";

export const queryClient =
  new QueryClient({
    defaultOptions: {
      queries: {
        retry: 1,
        staleTime: 1000 * 60 * 5,
      },
    },
  });
```

**What Do These Options Mean?**

```plaintext
retry: 1
```

If a query fails, React Query retries it once before marking it as failed.

```plaintext
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`.

```js
src/main.tsx
```

```javascript
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:

```javascript
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**.

```javascript
useQuery({
  queryKey: ["users"],
  queryFn: getUsers,
});

useQuery({
  queryKey: ["user", userId],
  queryFn: () => getUser(userId),
});
```

This creates separate cache entries:

```plaintext
["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)
    

```javascript
const createUserMutation = useMutation({
  mutationFn: createUser,
});
```

* * *

**3\. Loading and Error States**

React Query automatically provides request status.

**Queries**

```typescript
const {
  data,
  isLoading,
  isError,
} = useQuery(...);
```

**Mutations**

```javascript
const mutation = useMutation(...);

mutation.isPending;
mutation.isSuccess;
mutation.isError;
```

Example:

```javascript
<Button disabled={mutation.isPending}>
  {mutation.isPending
    ? "Creating..."
    : "Create Account"}
</Button>
```

* * *

**4\. Query Invalidation**

After creating or updating data, you often need fresh data.

```javascript
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.

```javascript
useQuery({
  queryKey: ["profile"],
  queryFn: getProfile,
  enabled: isLoggedIn,
});
```

The query executes only when:

```plaintext
isLoggedIn === true
```

* * *

**6\. Dependent Queries**

A query can depend on the result of another query.

```typescript
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.

```javascript
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.

```plaintext
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.

```plaintext
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**

```plaintext
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:

```plaintext
src/features/auth/api/auth.api.ts
```

I created all authentication API functions.

**Signup API**

```javascript
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**

```javascript
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**

```plaintext
src/features/auth/hooks/useSignupMutation.ts
```

```typescript
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**

```plaintext
src/features/auth/hooks/useLoginMutation.ts
```

```typescript
import { useMutation } from "@tanstack/react-query";
import { loginUser } from "../api/auth.api";

export const useLoginMutation = () => {
  return useMutation({
    mutationFn: loginUser,
  });
};
```

This gives us access to:

```plaintext
mutation.mutate()
mutation.isPending
mutation.isSuccess
mutation.isError
```

* * *

**Connecting Signup Form**

Inside the Signup form component, I used the custom signup mutation hook.

```typescript
const navigate = useNavigate();

const signupMutation =
  useSignupMutation();

const onSubmit = (
  data: SignupFormData
) => {
  signupMutation.mutate(data, {
    onSuccess: () => {
      navigate({
        to: "/",
      });
    },
  });
};
```

Flow:

```plaintext
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
    

```typescript
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](https://tanstack.com/router/latest/docs/how-to/setup-authentication?utm_source=chatgpt.com)

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.

```js
src/auth.tsx
```

```javascript
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`

```typescript
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:

```plaintext
context.auth
```

* * *

**Configure the Router**

**src/router.ts**

```javascript
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**

```typescript
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.

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

Everything inside `_authenticated` automatically becomes protected.

* * *

**Protected Layout Route**

**src/routes/\_authenticated.tsx**

```typescript
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:

```plaintext
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:

```plaintext
__root.tsx
    ↓
Outlet
    ↓
_authenticated.tsx
    ↓
Outlet
    ↓
dashboard.tsx
```

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

Without the second outlet:

```plaintext
__root
   ↓
_authenticated
```

the dashboard would never render.

* * *

**Dashboard Route**

```js
src/routes/_authenticated/dashboard.tsx
```

```typescript
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:

```plaintext
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**

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

  component: LoginPage,
});
```

Flow:

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

Notice that we're checking:

```plaintext
context.auth.isAuthenticated
```

instead of:

```plaintext
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**

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

At this moment:

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

* * *

**Step 2: AuthProvider executes**

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

Initially:

```plaintext
user = undefined
isLoading = true
isError = false
```

because the API request is still in progress.

* * *

**Step 3: Loading screen**

Because:

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

React renders:

```plaintext
Loading...
```

and **InnerApp does not render yet**.

This is important.

At this point:

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

* * *

**Step 4: getMe finishes**

Suppose the backend returns:

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

Then:

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

isLoading = false
isError = false
```

* * *

**Step 5: isAuthenticated**

You calculate:

```javascript
const isAuthenticated = !!user && !isError;
```

Since:

```plaintext
!!user === true
!isError === true
```

Result:

```plaintext
isAuthenticated = true
```

* * *

**Step 6: Context is created**

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

Now the context contains:

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

* * *

**Step 7: InnerApp renders**

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

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

`useAuth()` returns:

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

Then:

```plaintext
context={{
  auth,
}}
```

passes it into TanStack Router.

* * *

**Step 8: Router Context Becomes Available Everywhere**

Because the router receives the authentication state through context:

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

every route now has access to:

```plaintext
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:

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

Current value:

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

Since:

```plaintext
context.auth.isAuthenticated === true
```

TanStack Router immediately redirects:

```plaintext
/
 ↓
Already authenticated
 ↓
/dashboard
```

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

* * *

**Step 10: Protected Route Check**

Now suppose the user visits:

```plaintext
/dashboard
```

This route actually matches:

```plaintext
/_authenticated/dashboard
```

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

```plaintext
/_authenticated
```

which contains:

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

Current value:

```plaintext
context.auth.isAuthenticated === true
```

Therefore:

```plaintext
!true
```

becomes:

```plaintext
false
```

No redirect occurs and the dashboard is rendered.

* * *

**Complete Authentication Flow**

```plaintext
                  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

```js
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:

```plaintext
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**

```plaintext
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?**

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

This tells React Query:

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

React Query then executes:

```plaintext
getMe()
```

again.

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

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

* * *

**Final Login Flow**

```plaintext
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
```

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/40f76a3a-f63b-4233-82b7-64d255cafb87.gif align="center")

* * *

### 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](https://ui.shadcn.com/docs/components/sidebar?utm_source=chatgpt.com)

Then installed the sidebar block:

```plaintext
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`.

```js
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:

```plaintext
__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.

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

Dashboard Route:

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

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

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

Check Balance Route:

```plaintext
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.

```js
src/components/app-sidebar
```

```js
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:

```plaintext
  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:

```plaintext
{
  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.

```javascript
<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.

```js
src/components/nav-main.tsx
```

```javascript
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:

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

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

```plaintext
bun add @tabler/icons-react
```

Next, I created a dedicated `NavUser` component that displays:

```js
src/components/nav-user.tsx
```

```js
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:

```plaintext
<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:

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

Navigation flow:

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

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/7880ed4b-22dc-4150-b79c-8c3971185fd9.gif align="center")

* * *

### 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:

```javascript
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.

```js
src/components/members-table.tsx
```

```js
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.

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

* * *

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

```js
routes/_authenticated/Dashboard.tsx
```

```javascript
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
    

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/11997899-7c96-4fcf-9db8-3f280b509fa0.gif align="center")

* * *

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

```js
src/components/send-money-dialog.tsx
```

```javascript
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.

```js
src/features/transferMoney/api/account.api.ts
```

```javascript
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.

```js
src/features/transferMoney/hooks/useTransferMoney.ts
```

```javascript
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.

```javascript
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
    

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/fc14e965-ca47-49dd-ac0c-bdc9d7410f2c.gif align="center")

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.

```javascript
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.

```js
routes/_authenticated/CheckBalance.tsx
```

```plaintext
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.

```js
src/components/check-balance.tsx
```

```js
"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`.

```typescript
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.

```javascript
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
    

![](https://cdn.hashnode.com/uploads/covers/624226a5db84f8c50fa5b247/d85c8134-59e9-4447-91ad-8865a0b9c30f.gif align="center")

* * *

### 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](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! 🎉
