React + Vite + Go: Building with TanStack Router
Not every app needs server-side rendering. In this course, you will learn how to use TanStack Router with Vite as your frontend instead of Next.js — perfect for dashboards, internal tools, and admin panels where SEO doesn't matter and speed is everything.
Why TanStack Router?
Throughout the Grit Web course, you used Next.js as your frontend. Next.js is great for public-facing websites that need SEO, but it comes with overhead you don't always need — server-side rendering, server components, hydration. For apps where every user is logged in, there's a faster alternative.
Here's how TanStack Router + Vite compares to Next.js:
| Feature | TanStack Router + Vite | Next.js |
|---|---|---|
| Rendering | Client-side only (SPA) | Server + Client (SSR/SSG) |
| Dev startup | ~200ms (Vite HMR) | ~2-5s (compilation) |
| Bundle size | Smaller (no SSR runtime) | Larger (includes SSR) |
| SEO | Poor (client-rendered) | Excellent (pre-rendered) |
| Type safety | Full (routes, params, links) | Partial |
| Best for | Dashboards, admin panels, internal tools | Public websites, blogs, e-commerce |
Challenge: When Would You Choose TanStack Router?
For each of these projects, decide whether you'd use TanStack Router + Vite or Next.js, and explain why: (1) a company blog, (2) an employee timesheet tracker, (3) a real estate listing site, (4) a project management dashboard.
Scaffold with Vite
Grit supports Vite as an alternative frontend. When you pass the --vite flag, Grit scaffolds TanStack Router instead of Next.js:
grit new dashboard --double --viteThis creates a Double architecture (Go API + frontend) but with a Vite-powered React app instead of Next.js. Here's what's different:
| Next.js (--next) | TanStack Router (--vite) |
|---|---|
app/ directory | src/routes/ directory |
next.config.js | vite.config.ts |
layout.tsx for layouts | __root.tsx for root layout |
page.tsx for pages | index.tsx or about.tsx for pages |
Challenge: Scaffold and Compare
Scaffold two projects — one with --next and one with --vite. Compare the frontend folder structures. How many files are different? Which has more configuration files?
File-Based Routing
TanStack Router discovers routes from your file system, just like Next.js. But the naming conventions are different:
src/routes/
├── __root.tsx ← Root layout (navbar, providers)
├── index.tsx ← / (home page)
├── about.tsx ← /about
├── products/
│ ├── index.tsx ← /products (list)
│ ├── $id.tsx ← /products/:id (detail)
│ └── new.tsx ← /products/new (create)Key naming rules:
- • __root.tsx — the root layout that wraps every page (like Next.js' root
layout.tsx) - • index.tsx — the default page for a directory (like Next.js'
page.tsx) - • $param.tsx — a dynamic route segment (Next.js uses
[param]) - • _layout.tsx — a nested layout for a route group
routeTree.gen.ts file every time you add or remove a route file. This file contains the full type-safe route tree. Never edit it manually — it's regenerated automatically.Challenge: Create a New Route
Create a file at src/routes/settings.tsx with a simple component. Run the dev server — does TanStack Router pick it up automatically? Can you navigate to /settings?
Route Parameters
Files starting with $ create dynamic route parameters. The $id.tsx file matches any value in that URL segment and makes it available as a typed parameter:
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/products/$id')({
component: ProductDetail,
})
function ProductDetail() {
const { id } = Route.useParams()
return (
<div>
<h1>Product {id}</h1>
{/* Fetch and display product details */}
</div>
)
}The id parameter is fully type-safe. If you try to access a parameter that doesn't exist, TypeScript will catch the error at compile time. This is a major advantage over Next.js' params prop, which is loosely typed.
src/routes/teams/$teamId/members/$memberId.tsx gives you both teamId and memberId as typed params.Challenge: Create a Dynamic Route
Create a route at src/routes/users/$id.tsx that displays the user ID from the URL. Navigate to /users/42 and /users/hello — does the parameter work for both?
Layouts
The __root.tsx file is the root layout — it wraps every page in your app. This is where you place your navbar, providers (query client, auth context), and global styles:
import { createRootRoute, Outlet } from '@tanstack/react-router'
import { Navbar } from '../components/navbar'
export const Route = createRootRoute({
component: RootLayout,
})
function RootLayout() {
return (
<div className="min-h-screen bg-background">
<Navbar />
<Outlet />
</div>
)
}The Outlet component renders the current route's page — just like the children prop in Next.js layouts.
For nested layouts (e.g., a sidebar that only appears on dashboard pages), create a route group with a layout file:
src/routes/
├── dashboard/
│ ├── _layout.tsx ← Sidebar layout for all /dashboard/* routes
│ ├── index.tsx ← /dashboard (main dashboard)
│ ├── projects.tsx ← /dashboard/projects
│ └── settings.tsx ← /dashboard/settingsChallenge: Add a Sidebar Layout
Create a dashboard/_layout.tsx that renders a sidebar on the left and the page content on the right. Add at least 3 routes under /dashboard/. Does the sidebar persist across navigation?
Data Fetching
Data fetching in a Vite app is identical to what you learned in the Grit Web course. TanStack Query (useQuery, useMutation) works the same way — it's a React library, not a Next.js feature. The only difference is that everything runs client-side.
import { useQuery } from '@tanstack/react-query'
import { api } from '../../lib/api-client'
function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => api.get('/api/products'),
})
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error loading products</div>
return (
<div>
<h1>Products</h1>
{data.data.map((product: any) => (
<div key={product.id}>{product.name}</div>
))}
</div>
)
}Challenge: Fetch and Display Data
Generate a Product resource with grit generate. Then build a products list page that fetches from /api/products and displays them in a table. Does the loading state appear before the data?
Navigation
TanStack Router provides its own Link component and useNavigate hook. The Link component is type-safe — it only allows you to link to routes that actually exist:
import { Link, useNavigate } from '@tanstack/react-router'
// Declarative navigation (in JSX)
<Link to="/products">All Products</Link>
// With parameters — fully type-checked
<Link to="/products/$id" params={{ id: '42' }}>
View Product
</Link>
// Programmatic navigation (in event handlers)
function CreateButton() {
const navigate = useNavigate()
const handleCreate = async () => {
const product = await createProduct(data)
navigate({ to: '/products/$id', params: { id: product.id } })
}
return <button onClick={handleCreate}>Create</button>
}If you try to link to a route that doesn't exist, TypeScript shows an error immediately. If you forget a required parameter, TypeScript catches that too. This is a significant improvement over Next.js' Link component, which accepts any string.
Challenge: Add Navigation Links
Add a navbar with Link components to your root layout. Include links to Home, Products, and Dashboard. Then add a button that uses useNavigate to redirect to a product detail page after creating a product.
Auth in Vite Apps
Authentication in a Vite SPA works the same way as in Next.js, with one key difference: there's no server to manage cookies. Instead, you store the JWT token in localStorage and include it in every API request via an Authorization header.
// Auth context wraps the entire app
function AuthProvider({ children }) {
const [token, setToken] = useState(localStorage.getItem('token'))
const [user, setUser] = useState(null)
const login = async (email: string, password: string) => {
const res = await api.post('/api/auth/login', { email, password })
localStorage.setItem('token', res.data.token)
setToken(res.data.token)
setUser(res.data.user)
}
const logout = () => {
localStorage.removeItem('token')
setToken(null)
setUser(null)
}
return (
<AuthContext.Provider value={{ user, token, login, logout }}>
{children}
</AuthContext.Provider>
)
}To protect routes, check for the token and redirect to /login if not authenticated. You can do this in a layout component that wraps all protected routes:
function DashboardLayout() {
const { user } = useAuth()
const navigate = useNavigate()
if (!user) {
navigate({ to: '/login' })
return null
}
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-6">
<Outlet />
</main>
</div>
)
}Challenge: Implement a Protected Route
Set up an auth context with login/logout. Create a /login page and a protected /dashboard layout. Verify that visiting /dashboard without logging in redirects to /login.
Building for Production
When you're ready to deploy, build the Vite frontend into static files:
cd frontend && pnpm buildThis outputs optimized files to frontend/dist/. Vite tree-shakes unused code, minifies everything, and splits the bundle into chunks for optimal loading.
For Grit's Single architecture, the dist/ folder gets embedded directly into the Go binary via go:embed. This means your entire app — API and frontend — is a single executable file:
//go:embed all:frontend/dist
var frontendFS embed.FS
// Serve frontend from the embedded files
router.NoRoute(gin.WrapH(http.FileServer(http.FS(frontendFS))))Challenge: Build and Measure
Run pnpm build in the frontend directory. Check the output — what's the total bundle size? How many chunks were created? Compare this to a Next.js build if you have one available.
Vite Dev Server Proxy
During development, the Vite dev server runs on one port (typically 5173) and the Go API runs on another (typically 8080). The Vite proxy configuration forwards API requests to the Go server so you don't have to deal with CORS:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
export default defineConfig({
plugins: [TanStackRouterVite(), react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
})With this configuration, when your frontend code calls fetch("/api/products"), Vite intercepts the request and forwards it to http://localhost:8080/api/products. This only applies during development — in production, both frontend and API are served from the same origin.
Challenge: Explore the Proxy Config
Open vite.config.ts in your scaffolded project. Find the proxy configuration. What path prefix does it intercept? What happens if you change the Go server port — what else would you need to update?
When to Use Next.js vs Vite
Here's the decision framework:
| Criteria | Use Next.js | Use Vite + TanStack Router |
|---|---|---|
| SEO needed? | Yes — blog, e-commerce, landing pages | No — dashboards, admin panels |
| Public pages? | Many public pages | Mostly behind login |
| Build speed | Slower (webpack/turbopack) | Faster (Vite/esbuild) |
| Bundle size | Larger | Smaller |
| Deploy to | Vercel, VPS, Docker | Embedded in Go binary, VPS, CDN |
| Type safety | Partial (loose route params) | Full (routes, params, links) |
Challenge: Make the Right Choice
For each project below, decide Next.js or Vite + TanStack Router, and explain your reasoning: (1) a personal blog with comments, (2) a company HR dashboard, (3) an online store with 10,000 products, (4) an internal inventory management tool, (5) a SaaS landing page + app.
What You Learned
- When to choose TanStack Router + Vite over Next.js
- How to scaffold a Vite project with
grit new --vite - File-based routing with TanStack Router
- Type-safe route parameters, layouts, and navigation
- Data fetching with TanStack Query (same as Next.js)
- JWT auth with localStorage for SPAs
- Vite dev server proxy for API calls
- Building for production and embedding in Go
Challenge: Build a Task Dashboard
Build a complete task dashboard from scratch:
- Scaffold with
grit new task-dash --double --vite - Generate a Task resource:
grit generate resource Task title:string status:string priority:int due_date:date:optional - Build a task list page with filtering by status
- Build a create/edit task form
- Add an auth guard on all routes
- Build and check the production bundle size
Enjoying the course?
Help us grow — star us on GitHub, subscribe on YouTube, and follow on LinkedIn.