Skip to main content

Command Palette

Search for a command to run...

Building the Frontend of CodeLens AI with TanStack Start

Updated
20 min readView as Markdown
Building the Frontend of CodeLens AI with TanStack Start
S

I'm Shubham (@shubhamsinghbundela), I'm a Software Engineer, a Full-stack developer, a tech enthusiast, and a technical writer here on @Hashnode. I have a strong zeal to share my acquired knowledge and I am also willing to learn from others.

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 application

  • server → 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 dev script from the package named web.

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:

  • clsx handles conditional class names.

  • tailwind-merge resolves 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 ThemeProvider

  • Using ScriptOnce to handle the theme before React hydration

  • Wrapping the root layout with ThemeProvider

  • Adding suppressHydrationWarning to the <html> element

  • Creating 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
N

Great tutorial 👍🏼