Building the Frontend of CodeLens AI with TanStack Start

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.
Before diving into the implementation of the CodeLens AI frontend, let's first look at how the frontend project is organized.
The frontend is built inside a pnpm monorepo, which allows the project to keep the web application, backend server, and WebSocket service in separate packages while managing them from a single repository.
For the frontend, we're using TanStack Start, TanStack Router, TanStack Query, Shadcn and Tailwind CSS.
The goal of this setup is to keep the frontend modular, type-safe, and easy to maintain as the application grows.
Project Architecture
The project follows a monorepo structure:
CodeLens-AI/
├── packages/
│ ├── web/
│ ├── server/
│
├── package.json
├── pnpm-workspace.yaml
└── pnpm-lock.yaml
Each package has a specific responsibility:
web→ Frontend applicationserver→ Backend/API server
This allows us to develop and manage different parts of the system independently while keeping everything inside one repository.
Why a Monorepo?
Instead of creating separate repositories for the frontend, backend service, we keep them together in a single repository.
This gives us:
Shared configuration
Easier dependency management
Consistent tooling
Easier local development
A single Git history
Ability to share code between packages later
For example, if the frontend and backend eventually need shared TypeScript types, we can introduce another package:
packages/
├── web/
├── server/
└── shared/
The shared package could contain things like API types, schemas, or common utilities.
Step1: Setting Up pnpm
The first step was installing the project dependencies:
pnpm install
Then I initialized the root package:
pnpm init
This created the root package.json.
The root package.json contains the configuration that applies to the entire repository.
For example:
{
"name": "CodeLens-AI",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev:web": "pnpm --filter web dev",
"lint": "pnpm --filter web lint",
"check": "oxfmt --check",
"format": "oxfmt",
"lint:fix": "pnpm --filter web \"lint:fix\"",
"prepare": "lefthook install"
}
}
What is pnpm --filter web?
One of the useful features of pnpm workspaces is the --filter option.
For example:
pnpm --filter web dev
means:
Run the
devscript from the package namedweb.
So instead of navigating into:
cd packages/web
pnpm dev
we can run it directly from the repository root:
pnpm --filter web dev
This becomes particularly useful as the monorepo grows.
For example:
pnpm --filter web dev
pnpm --filter web lint
The first command starts the frontend, while the second runs the frontend's linting process.
pnpm Workspace Configuration
To tell pnpm which directories belong to the monorepo, we create:
pnpm-workspace.yaml
The configuration is:
packages:
- "packages/*"
allowBuilds:
lefthook: true
The first part:
packages:
- "packages/*"
basically tells pnpm:
Treat the packages inside
packages/as workspace packages.
So these become workspace packages:
packages/
├── web/
├── server/
Why pnpm-lock.yaml?
pnpm also generates:
pnpm-lock.yaml
The lockfile records the exact dependency versions used by the project.
This helps ensure that different developers and CI environments install consistent dependency versions.
Instead of one developer getting one version and another developer getting a slightly different version, the lockfile keeps the dependency tree reproducible.
Code Quality and Git Hooks
Once the basic monorepo structure was ready, the next step was setting up development tooling.
CodeLens AI uses:
Oxlint → linting
Oxfmt → formatting
Lefthook → Git hooks
The goal is to catch problems automatically before code reaches the repository.
Why Lefthook?
In a team project, we don't want developers to manually remember:
pnpm lint
pnpm format
before every commit.
Instead, Git hooks can automatically run checks.
Developer
|
| git commit
v
Lefthook
|
v
Run configured checks
|
├── Pass → Commit
|
└── Fail → Stop commit
For example, if linting finds an error, the commit can be blocked until the developer fixes the problem.
Why Lefthook Instead of Husky?
Husky is a popular Git hook manager, but Lefthook is another alternative.
A Git hook manager allows us to configure commands that automatically execute during Git events such as:
git commit
git push
For this project, I chose Lefthook to manage these Git hooks.
The important idea isn't the specific tool. The important idea is:
Automate development checks instead of relying on developers to remember them manually.
Why Is Lefthook Installed at the Root?
Because this is a monorepo, Git belongs to the entire repository rather than an individual package.
Therefore, Git hooks are generally configured at the repository root.
CodeLens-AI/
│
├── packages/
│ ├── web/
│ ├── server/
│ └── websocket/
│
├── lefthook.yml
└── package.json
This allows one Git hook configuration to control checks across the entire monorepo.
The prepare Script
The root package.json contains:
{
"scripts": {
"prepare": "lefthook install"
}
}
prepare is a package-manager lifecycle script.
When another developer clones the repository and runs:
pnpm install
the lifecycle becomes:
pnpm install
|
v
Dependencies installed
|
v
prepare script
|
v
lefthook install
|
v
Git hooks configured
This means developers don't need to manually remember to run:
lefthook install
after installing the project.
pnpm 11 and allowBuilds
While setting up Lefthook, I encountered an issue with pnpm 11:
ERR_PNPM_IGNORED_BUILDS
Ignored build scripts: lefthook
This happens because some dependencies need to execute installation/build scripts.
For security reasons, pnpm can block these scripts unless they are explicitly allowed.
The workspace configuration therefore contains:
allowBuilds:
lefthook: true
This tells pnpm:
Lefthook is trusted to execute its required build/install script.
Another way to manage these approvals is:
pnpm approve-builds
pnpm then shows dependencies requesting permission to execute scripts.
Why Does pnpm Restrict Build Scripts?
Consider a dependency containing:
{
"scripts": {
"postinstall": "some-command"
}
}
If package installation scripts were automatically trusted, a malicious package could potentially execute unwanted commands on a developer's machine.
The security model is roughly:
Dependency installed
|
v
Wants to execute script
|
v
pnpm checks
|
Approved?
/ \
YES NO
| |
Run Block
This is particularly important in large projects where the dependency tree can contain hundreds or thousands of packages.
Why Oxfmt at the Root?
Formatting is slightly different from application-specific linting.
Oxfmt is used as a repository-level formatting tool:
{
"scripts": {
"check": "oxfmt --check",
"format": "oxfmt"
}
}
So we can format the repository from the root:
pnpm format
and check formatting with:
pnpm check
The distinction is:
Oxlint
↓
Checks application/source code
↓
packages/web
Oxfmt
↓
Formats repository code
↓
Root
This separation becomes more useful as more packages are added to the monorepo.
Step2: Setting Up the Web Package
After setting up the monorepo, I created the frontend package:
packages/web/
This package contains the CodeLens AI frontend.
The frontend is built using:
TanStack Start
|
├── TanStack Router
├── TanStack Query
└── React
This gives us routing, server/client rendering capabilities, and server-state management while keeping the frontend strongly typed.
Understanding the Web Package Structure
After setting up the monorepo, I created the web package for the CodeLens AI frontend.
The frontend uses React with TanStack Start, with TanStack Router handling the application's routing.
The structure currently looks like this:
CodeLens-AI/
├── packages/
│ ├── server/ ← Backend/server package
│ │
│ └── web/ ← Frontend + TanStack Start
│ │
│ ├── src/
│ │ ├── routes/
│ │ │ ├── root.tsx
│ │ │ └── index.tsx
│ │ │
│ │ ├── router.tsx
│ │ ├── routeTree.gen.ts
│ │ └── styles.css
│ │
│ ├── package.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ └── ...
│
├── package.json
├── pnpm-workspace.yaml
└── pnpm-lock.yaml
web
The web package contains the React/TanStack Start application.
All frontend-specific code lives inside this package, keeping it separate from the backend and other services.
File-Based Routing with TanStack Router
TanStack Router uses file-based routing, which means the files inside the routes directory represent routes in the application.
For example:
routes/
├── index.tsx → /
├── about.tsx → /about
└── dashboard.tsx → /dashboard
So instead of manually defining every route in one large routing configuration, the application's file structure itself represents the routing structure.
This becomes especially useful as the application grows because routes can be organized naturally using folders and files.
root.tsx
root.tsx is the root route of the application.
It acts as the top-level layout for the routes and is a place for functionality that should be common across the application.
For example, it can contain:
Global layouts
Navigation
Common providers
Application-wide UI
Error and loading boundaries
Document-level configuration
In CodeLens AI, the root route provides the foundation around which the rest of the application's routes are rendered.
index.tsx
The index.tsx file represents the root / route.
routes/
└── index.tsx → /
This is typically the page users see when they visit the application's base URL.
router.tsx
The router.tsx file is responsible for creating and configuring the TanStack Router instance.
Conceptually, its job is:
URL
↓
TanStack Router
↓
Find matching route
↓
Render corresponding component
For example:
/user/profile
↓
TanStack Router
↓
routes/user/profile.tsx
↓
Profile Page
This file is also where router-level configuration such as preloading and scroll restoration can be defined.
routeTree.gen.ts
routeTree.gen.ts is a generated file created by TanStack Router's tooling.
It represents the application's route tree based on the files inside the routes directory.
Conceptually:
routes/
├── index.tsx
├── about.tsx
└── dashboard.tsx
↓
TanStack Router tooling
↓
routeTree.gen.ts
The generated route tree allows TanStack Router to understand the application's routes and provide type-safe routing.
Because this file is generated automatically, we generally don't edit it manually. Instead, we modify the route files and let TanStack Router regenerate the route tree.
Putting It Together
The relationship between these files can be summarized as:
TanStack Start
|
v
TanStack Router
|
v
routes/
|
├── root.tsx
├── index.tsx
└── other routes
|
v
Generated Route Tree
|
v
Application UI
This gives the frontend a clear separation between the application's route structure, router configuration, and individual pages.
Step3: How Does the Application Flow Work?
Now that we understand the purpose of each file, let's see what happens when a user actually opens the application.
Suppose a user visits:
/
The high-level flow looks like this:
User opens /
↓
Application starts
↓
router.tsx
↓
routeTree.gen.ts
↓
__root.tsx
↓
index.tsx
↓
Home Page
Let's understand each step.
1. User Opens /
The user opens the CodeLens AI application in their browser:
https://codelens-ai.com/
The browser requests the / route from the application.
2. Application Starts
TanStack Start initializes the application.
The router configuration is created in:
src/router.tsx
This is where we create the TanStack Router instance and provide the application's route tree.
3. router.tsx
The router is responsible for understanding the application's routes.
Conceptually:
URL: /
↓
TanStack Router
↓
Which route matches "/"?
The router uses the generated route tree to find the matching route.
4. routeTree.gen.ts
TanStack Router generates the route tree from the files inside the routes directory.
For example:
routes/
├── __root.tsx
├── index.tsx
└── dashboard.tsx
The generated route tree understands that:
/ → index.tsx
/dashboard → dashboard.tsx
So when the user requests /, TanStack Router knows that index.tsx is the route that should render.
5. __root.tsx
Before rendering the individual page, the root route is involved.
__root.tsx acts as the application's top-level route/layout.
This is where we can define things that should be available across multiple routes, such as:
Global providers
Navigation
Theme provider
Common layouts
Global UI
Document configuration
Conceptually:
__root.tsx
|
└── Child Route
|
└── index.tsx
6. index.tsx
Because the user requested /, TanStack Router matches the index route:
routes/index.tsx
↓
/
The component inside index.tsx is rendered as the page content.
7. Home Page
Finally, the user sees the Home Page in the browser.
So the complete flow is:
User
|
| opens /
↓
TanStack Start
|
↓
router.tsx
|
↓
routeTree.gen.ts
|
↓
__root.tsx
|
↓
index.tsx
|
↓
Home Page
This is the basic flow of how a URL gets translated into a rendered page in the CodeLens AI frontend.
As more routes are added, the same routing mechanism handles them:
/ → index.tsx
/dashboard → dashboard.tsx
/settings → settings.tsx
/reviews → reviews.tsx
This file-based routing approach keeps the application's URL structure closely aligned with its code structure.
Step4. Shadcn Setup
After setting up the web package with TanStack Start, the project already had Tailwind CSS configured as part of the frontend template.
I then initialized shadcn/ui using:
pnpm dlx shadcn@latest init --preset b0 --template start
I wanted to understand what this command actually does, especially how shadcn works with a Tailwind CSS v4 project.
What happens when you run this command?
Roughly, the shadcn initialization does the following:
shadcn init
│
├── creates components.json
│
├── creates src/lib/utils.ts
│
├── configures shadcn components
│
├── modifies global CSS
│
└── configures the shadcn theme for Tailwind v4
An important point is that Tailwind CSS was already present in the project through the TanStack Start template.
So shadcn is not installing Tailwind from scratch. Instead, it configures shadcn/ui to work with the existing Tailwind CSS setup.
Where is tailwind.config.js?
This is where Tailwind CSS v4 is different from Tailwind CSS v3.
Tailwind CSS v3
In Tailwind v3, you would normally have a:
tailwind.config.js
For example:
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
And the global CSS typically contained:
@tailwind base;
@tailwind components;
@tailwind utilities;
The JavaScript configuration file was the main place for configuring Tailwind.
Tailwind CSS v4 Changed This
This project uses:
"tailwindcss": "^4.1.18"
Therefore, it is using Tailwind CSS v4.
Tailwind v4 introduced a CSS-first configuration approach.
Instead of relying primarily on:
tailwind.config.js
the configuration can be defined directly in CSS.
In this project, the important file is:
src/
└── styles.css
It contains:
@import "tailwindcss";
This imports Tailwind CSS into the application.
How Does Tailwind Get Connected to Vite?
Tailwind v4 provides a dedicated Vite integration:
"@tailwindcss/vite": "^4.1.18"
The Vite configuration contains the Tailwind plugin:
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
});
The important part is:
tailwindcss()
This tells Vite to process Tailwind CSS as part of the application's build process.
The relationship looks like:
Vite
│
├── React plugin
│
└── Tailwind Vite plugin
│
↓
styles.css
│
↓
Tailwind CSS v4
Where Is the Actual Tailwind Theme Configuration?
This is where the shadcn setup becomes interesting.
The src/styles.css file contains Tailwind and shadcn theme configuration.
For example:
@import "tailwindcss";
@import "tw-animate-css";
shadcn also defines design tokens using CSS variables:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--border: oklch(0.922 0 0);
}
Then those variables are exposed to Tailwind through @theme:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-border: var(--border);
}
So in Tailwind v4, the CSS file is doing much more than simply importing Tailwind. It can also define the application's theme and design tokens.
What Does @theme Do?
Consider:
@theme inline {
--color-primary: var(--primary);
}
This tells Tailwind that primary should be available as a theme color.
We can then use:
<button className="bg-primary text-primary-foreground">
Login
</button>
The relationship is:
CSS variable
↓
@theme
↓
Tailwind theme token
↓
Tailwind utility
↓
bg-primary
This allows the application to use semantic names such as:
primary
background
foreground
border
instead of hard-coding colors throughout every component.
What Are --background and --primary?
Variables such as:
--background
--foreground
--primary
are CSS custom properties.
For example:
:root {
--background: white;
--foreground: black;
}
We then connect them to Tailwind:
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
Now a component can use:
<div className="bg-background text-foreground">
Hello
</div>
Instead of directly writing:
<div className="bg-white text-black">
This gives us semantic design tokens that can change depending on the active theme.
What Is components.json?
The components.json file is shadcn configuration, not Tailwind configuration.
The initialization command creates this file so that shadcn knows how the project is structured.
It contains information such as:
{
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/styles.css"
},
"aliases": {
"components": "#/components",
"utils": "#/lib/utils"
}
}
The exact values depend on the preset and template being used.
The easiest way to think about it is:
components.json
↓
shadcn configuration
while:
styles.css
↓
Tailwind CSS + theme configuration
So these two files have different responsibilities.
What Is src/lib/utils.ts?
shadcn also creates:
src/lib/utils.ts
This file contains the cn() utility commonly used by shadcn components.
A typical implementation looks like:
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
The purpose of cn() is to make it easier to combine conditional and Tailwind classes.
For example:
<Button
className={cn(
"bg-primary",
someCondition && "bg-red-500"
)}
>
Login
</Button>
Here:
clsxhandles conditional class names.tailwind-mergeresolves conflicting Tailwind utility classes.
So instead of manually constructing class strings, components can use:
cn(...)
to combine them safely.
Step5: Adding Dark Mode with shadcn/ui
To add dark mode to the CodeLens AI frontend, I followed the official shadcn/ui guide for TanStack Start.
The guide covers the complete setup, including:
Creating the
ThemeProviderUsing
ScriptOnceto handle the theme before React hydrationWrapping the root layout with
ThemeProviderAdding
suppressHydrationWarningto the<html>elementCreating the light/dark/system mode toggle
This approach is specifically designed for TanStack Start and handles the SSR and hydration considerations involved with theme switching.
Setup guide:
shadcn/ui — Dark Mode for TanStack Start
I followed this guide rather than implementing a separate dark-mode architecture from scratch.
Step6: Issue I Faced: TanStack Version Compatibility
While setting up dark mode in my TanStack Start application, I encountered a 500 Internal Server Error during SSR.
Initially, I suspected that the issue was related to the dark-mode implementation. However, after debugging, I found that the problem was caused by an incompatible combination of TanStack package versions.
I had initially used latest for the TanStack packages. Since TanStack Start depends on several closely related TanStack packages, using mismatched versions can lead to unexpected issues during server-side rendering.
The problem looked roughly like this:
TanStack Start
↓
TanStack packages
↓
Incompatible versions
↓
SSR failure
↓
500 Internal Server Error
How I Fixed It
I pinned the TanStack packages to compatible versions instead of using latest.
Before
TanStack packages
↓
latest versions
↓
Version mismatch
↓
SSR 500 error
After pinning compatible versions:
TanStack packages
↓
Compatible versions
↓
SSR works correctly
↓
Application loads normally
After making this change, the 500 SSR error was resolved.
What I Learned
This was a useful lesson about dependency management in framework ecosystems.
When a framework is composed of multiple closely related packages, using latest for every package does not necessarily guarantee that all of them are compatible with each other.
So when debugging an SSR error, it is worth checking:
Application code
↓
Framework configuration
↓
Package versions
↓
SSR / hydration
In my case, the issue was not the dark-mode implementation itself. The problem was the TanStack package version combination.
For the dark-mode implementation itself, I followed the official shadcn/ui TanStack Start dark mode guide, which specifically covers the SSR/hydration considerations for TanStack Start.
Step7: Setting Up TanStack Query
After setting up TanStack Start and the UI foundation, I configured TanStack Query to manage server state in the frontend.
Installing TanStack Query
I installed TanStack Query using:
pnpm add @tanstack/react-query
This added the @tanstack/react-query package to the project.
Creating the QueryClient
The next step was configuring TanStack Query in router.tsx.
I imported:
import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
Then I created a QueryClient:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
},
},
});
What is QueryClient?
QueryClient is the main manager of TanStack Query.
It is responsible for things such as:
Caching API data
Managing API requests
Managing stale data
Controlling refetching
Managing query state
Conceptually:
API / Server
↓
QueryClient
↓
Query Cache
↓
React Components
Configuring QueryClientProvider
Creating a QueryClient alone isn't enough.
React components need access to that client. For this, TanStack Query provides QueryClientProvider.
I wrapped the components rendered by TanStack Router inside the provider:
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
The relevant part of router.tsx therefore looks like:
export function getRouter() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5,
},
},
});
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
Wrap: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
return router;
}
Why Wrap?
TanStack Router's Wrap allows us to wrap the router-rendered application with another React component.
In this case:
TanStack Router
↓
Wrap
↓
QueryClientProvider
↓
Route Components
Therefore, components rendered inside the router have access to the QueryClient.
This means that inside any route or component, I can use:
useQuery(...)
because the component is rendered inside:
<QueryClientProvider>
How the Data Flow Works Now
The relationship between these pieces is:
User opens application
↓
TanStack Router
↓
Route Component
↓
useQuery()
↓
QueryClient
↓
API
↓
QueryClient Cache
↓
Component receives data



