engineering

Why We Chose TanStack Start for Our Dashboard

A deep dive into our decision to build the Convobase dashboard with TanStack Start instead of Next.js or other full-stack React frameworks.

C
Convobase Team
Jan 25, 20247 min read
#tanstack#react#frontend#developer-experience

Why We Chose TanStack Start for Our Dashboard

When building the Convobase dashboard, we evaluated several full-stack React frameworks. Here's why we landed on TanStack Start and haven't looked back.

The Requirements

Our dashboard needed to be:

  • Fast - Sub-second page loads
  • Type-safe - End-to-end TypeScript
  • Flexible - Easy to customize and extend
  • Modern - Latest React patterns and tooling
  • Maintainable - Clear architecture and minimal magic

The Contenders

Next.js App Router

// Next.js approach
export default async function Page({ params }: { params: { id: string } }) {
  const data = await fetch(`/api/deployments/${params.id}`)
  return <DeploymentDetail deployment={data} />
}

// API route
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const id = searchParams.get('id')
  return Response.json(await getDeployment(id))
}

Pros:

  • Mature ecosystem
  • Great documentation
  • ISR and streaming

Cons:

  • App router complexity
  • Caching confusion
  • Server component limitations

Remix

// Remix approach
export async function loader({ params }: LoaderFunctionArgs) {
  return json(await getDeployment(params.id))
}

export default function DeploymentDetail() {
  const deployment = useLoaderData<typeof loader>()
  return <DeploymentDetailComponent deployment={deployment} />
}

Pros:

  • Excellent DX
  • Web platform focus
  • Clear mental model

Cons:

  • Route-based only
  • Limited client state
  • Deployment complexity

TanStack Start

// TanStack Start approach
export const Route = createFileRoute('/deployments/$deploymentId')({
  loader: ({ params }) => getDeployment(params.deploymentId),
  component: DeploymentDetail,
})

function DeploymentDetail() {
  const deployment = Route.useLoaderData()
  return <DeploymentDetailComponent deployment={deployment} />
}

Pros:

  • Type-safe by default
  • Flexible architecture
  • Great developer experience
  • Minimal magic

Why TanStack Start Won

1. Unmatched Type Safety

The type safety in TanStack Start goes beyond what other frameworks offer:

// Automatic type inference from loaders
export const Route = createFileRoute('/deployments/$deploymentId')({
  loader: async ({ params }) => {
    // params is automatically typed as { deploymentId: string }
    const deployment = await getDeployment(params.deploymentId)
    return { deployment } // Return type automatically inferred
  },
  component: DeploymentDetail,
})

function DeploymentDetail() {
  // deployment is fully typed without manual interfaces
  const { deployment } = Route.useLoaderData()
  
  // TypeScript knows deployment structure
  return (
    <div>
      <h1>{deployment.name}</h1>
      <p>Status: {deployment.status}</p>
      {/* Full autocomplete and type checking */}
    </div>
  )
}

2. Route-Level Data Loading

TanStack Router's loader pattern is cleaner than alternatives:

// Complex data loading with dependencies
export const Route = createFileRoute('/deployments/$deploymentId/metrics')({
  loader: async ({ params, context }) => {
    // Parallel data loading
    const [deployment, metrics, logs] = await Promise.all([
      getDeployment(params.deploymentId),
      getMetrics(params.deploymentId),
      getLogs(params.deploymentId, { limit: 100 })
    ])
    
    return { deployment, metrics, logs }
  },
  pendingComponent: () => <MetricsLoading />,
  errorComponent: ({ error }) => <MetricsError error={error} />,
  component: DeploymentMetrics,
})

3. Flexible Architecture

Unlike Next.js or Remix, TanStack Start doesn't force architectural decisions:

// Server functions - call from anywhere
export const updateDeployment = createServerFn(
  'POST',
  async (data: UpdateDeploymentData) => {
    return await deploymentService.update(data)
  }
)

// Use in components
function DeploymentControls({ deploymentId }: Props) {
  const mutation = useMutation({
    mutationFn: updateDeployment,
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries(['deployments', deploymentId])
    }
  })
  
  return (
    <Button 
      onClick={() => mutation.mutate({ id: deploymentId, status: 'stopped' })}
    >
      Stop Deployment
    </Button>
  )
}

4. Outstanding Developer Experience

The development workflow with TanStack Start is exceptional:

// Hot reloading works perfectly
// Type checking is instant  
// Error boundaries are clear
// Debugging is straightforward

// Example: Search with real-time updates
export const Route = createFileRoute('/deployments/')({
  validateSearch: (search) => ({
    status: search.status as DeploymentStatus | undefined,
    search: search.search as string | undefined,
  }),
  loader: async ({ search }) => {
    const deployments = await getDeployments({
      status: search.status,
      search: search.search
    })
    return { deployments }
  },
  component: DeploymentsList,
})

function DeploymentsList() {
  const { deployments } = Route.useLoaderData()
  const navigate = Route.useNavigate()
  
  // URL state management is automatic
  const updateSearch = (newSearch: string) => {
    navigate({ search: { search: newSearch } })
  }
  
  return (
    <div>
      <SearchInput onChange={updateSearch} />
      {deployments.map(deployment => (
        <DeploymentCard key={deployment.id} deployment={deployment} />
      ))}
    </div>
  )
}

Real-World Implementation

Here's how we structure our Convobase dashboard:

File Organization

src/
├── routes/
│   ├── __root.tsx              # Root layout
│   ├── index.tsx              # Dashboard home
│   ├── deployments/
│   │   ├── index.tsx          # Deployments list  
│   │   ├── $deploymentId.tsx  # Deployment detail
│   │   └── new.tsx            # Create deployment
│   ├── settings/
│   │   ├── index.tsx          # Settings overview
│   │   ├── api-keys.tsx       # API key management
│   │   └── billing.tsx        # Billing settings
│   └── blog/                  # Blog routes (this implementation!)
│       ├── index.tsx
│       └── $slug.tsx
├── components/
│   ├── ui/                    # shadcn components
│   ├── dashboard/             # Dashboard-specific components
│   └── blog/                  # Blog components
└── lib/
    ├── api.ts                 # API client
    ├── auth.ts                # Authentication
    └── utils.ts               # Utilities

Authentication Integration

// __root.tsx - Auth context
export const Route = createRootRoute({
  beforeLoad: async () => {
    // Check authentication status
    const user = await getCurrentUser()
    return { user }
  },
  component: RootComponent,
})

// Protected route example
export const Route = createFileRoute('/deployments/')({
  beforeLoad: ({ context }) => {
    if (!context.user) {
      throw redirect({ to: '/login' })
    }
  },
  loader: async ({ context }) => {
    // context.user is guaranteed to exist here
    const deployments = await getDeployments(context.user.id)
    return { deployments }
  },
})

Error Handling

// Global error boundary
function RootErrorComponent({ error }: { error: Error }) {
  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-2xl font-bold text-red-600">
          Something went wrong
        </h1>
        <p className="mt-2 text-gray-600">
          {error.message}
        </p>
        <Button 
          className="mt-4"
          onClick={() => window.location.reload()}
        >
          Reload Page
        </Button>
      </div>
    </div>
  )
}

Performance Results

After 3 months in production:

  • Initial load: 1.2s (vs 2.1s with Next.js)
  • Route transitions: 150ms average
  • Bundle size: 30% smaller than equivalent Next.js app
  • Type checking: 3x faster than our previous setup

Challenges and Solutions

1. Learning Curve

Challenge: New patterns to learn Solution: Excellent documentation and TypeScript guidance

2. Ecosystem

Challenge: Smaller ecosystem than Next.js Solution: Most React libraries work perfectly

3. Deployment

Challenge: Newer deployment targets Solution: Works great with Vercel, Netlify, and our own infrastructure

Migration Tips

If you're considering TanStack Start:

1. Start Small

Begin with a simple page and gradually migrate:

// Start with a basic route
export const Route = createFileRoute('/dashboard')({
  component: () => <div>Hello TanStack Start!</div>
})

2. Leverage Types

Let TypeScript guide your implementation:

// Types flow automatically through the router
export const Route = createFileRoute('/api-keys')({
  loader: () => getApiKeys(), // Return type inferred
  component: ApiKeysList
})

function ApiKeysList() {
  const apiKeys = Route.useLoaderData() // Fully typed
  // ... component implementation
}

3. Use Server Functions

Replace API routes with type-safe server functions:

// Instead of separate API route
export const createApiKey = createServerFn('POST', async (data) => {
  return await apiKeyService.create(data)
})

// Use directly in components
const mutation = useMutation({
  mutationFn: createApiKey
})

Conclusion

TanStack Start has exceeded our expectations. The type safety, developer experience, and performance make it an excellent choice for modern React applications.

The framework feels like the natural evolution of React development - embracing TypeScript fully while maintaining the flexibility that makes React great.


Considering TanStack Start for your next project? We're happy to share more details about our implementation. Reach out at engineering@convobase.com

C

Convobase Team

Building AI-native chat infrastructure for the next generation of applications. We're passionate about creating developer-friendly tools that make streaming conversations and intelligent context management effortless.