tcbs-stack
TCBS Stack
Build production-ready full-stack TypeScript applications with TanStack Start + Convex + Better-Auth + Shadcn UI.
Stack Overview
| Layer | Technology | Purpose |
|---|---|---|
| Frontend | TanStack Start | Full-stack React framework with SSR |
| Backend | Convex | Real-time database & serverless functions |
| Auth | Better-Auth | Flexible authentication with Convex adapter |
| UI | Shadcn UI | Accessible component library |
Quick Start
# Create new TanStack Start project with Shadcn (recommended)
bun create @tanstack/start@latest --tailwind --add-ons shadcn
cd my-app
# Add Convex (requires v1.25.0+)
bun add convex@latest @convex-dev/react-query
bunx convex dev --once
# Add Better-Auth with Convex
bun add better-auth @convex-dev/better-auth
Alternative manual setup:
bun create @tanstack/start@latest
bun add tailwindcss @tailwindcss/vite
bunx shadcn@latest init
Project Structure
project/
├── src/
│ ├── lib/
│ │ ├── auth-client.tsx # Client auth setup
│ │ ├── auth-server.ts # Server auth handler
│ │ └── utils.ts # cn() helper
│ ├── routes/
│ │ ├── __root.tsx # Root layout + providers
│ │ ├── _authed.tsx # Protected route guard
│ │ ├── _authed/
│ │ │ └── index.tsx # Dashboard
│ │ ├── sign-in.tsx
│ │ └── sign-up.tsx
│ ├── components/
│ │ └── ui/ # Shadcn components
│ └── router.tsx
├── convex/
│ ├── auth.ts # Better-Auth instance
│ ├── auth.config.ts # Auth providers config
│ ├── http.ts # HTTP routes
│ ├── schema.ts # Database schema
│ └── convex.config.ts # App config
├── components.json # Shadcn config
└── vite.config.ts
Core Setup Files
1. Convex Configuration
convex/convex.config.ts
import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";
const app = defineApp();
app.use(betterAuth);
export default app;
convex/auth.config.ts
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import { AuthConfig } from "@auth/core";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;
convex/auth.ts
import { createClient, GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { betterAuth } from "better-auth";
import { DataModel } from "./_generated/dataModel";
import { components, internal } from "./_generated/api";
import authConfig from "./auth.config";
const siteUrl = process.env.SITE_URL!;
export const authComponent = createClient<DataModel>(components.betterAuth, {
authFunctions: internal.auth,
triggers: {
user: {
onCreate: async (ctx, authUser) => {
// Sync to your users table
const userId = await ctx.db.insert("users", { email: authUser.email });
await authComponent.setUserId(ctx, authUser._id, userId);
},
},
},
});
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
emailAndPassword: { enabled: true },
plugins: [convex({ authConfig })],
});
};
// Auth helpers for queries/mutations
export const { getUser, safeGetUser } = authComponent.authHelpers(createAuth);
convex/http.ts
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth);
export default http;
convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
email: v.string(),
authId: v.optional(v.string()),
}).index("email", ["email"]),
});
2. Client Auth Setup
src/lib/auth-client.tsx
import { createAuthClient } from "better-auth/react";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [convexClient()],
});
export const { signIn, signUp, signOut, useSession } = authClient;
3. Server Auth Setup
src/lib/auth-server.ts
import { convexBetterAuthReactStart } from "@convex-dev/better-auth/react-start";
export const {
handler,
getToken,
fetchAuthQuery,
fetchAuthMutation,
fetchAuthAction,
} = convexBetterAuthReactStart({
convexUrl: process.env.VITE_CONVEX_URL!,
convexSiteUrl: process.env.VITE_CONVEX_SITE_URL!,
});
3b. Mount Auth Route Handler
src/routes/api/auth/$.ts
import { handler } from "~/lib/auth-server";
export const { GET, POST } = handler;
4. Root Route with Providers
src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { QueryClient } from "@tanstack/react-query";
import { ConvexQueryClient } from "@convex-dev/react-query";
import { getToken } from "~/lib/auth-server";
import { authClient } from "~/lib/auth-client";
const getAuth = createServerFn({ method: "GET" }).handler(async () => {
return await getToken();
});
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
convexQueryClient: ConvexQueryClient;
}>()({
beforeLoad: async (ctx) => {
const token = await getAuth();
if (token) {
ctx.context.convexQueryClient.serverHttpClient?.setAuth(token);
}
return { isAuthenticated: !!token, token };
},
component: RootComponent,
});
function RootComponent() {
const { convexQueryClient, token } = Route.useRouteContext();
return (
<ConvexBetterAuthProvider
client={convexQueryClient.convexClient}
authClient={authClient}
initialToken={token}
>
<Outlet />
</ConvexBetterAuthProvider>
);
}
5. Protected Route Guard
src/routes/_authed.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => {
if (!context.isAuthenticated) {
throw redirect({ to: "/sign-in" });
}
},
component: () => <Outlet />,
});
6. Vite Configuration
vite.config.ts
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [tanstackStart(), react(), tailwindcss()],
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
ssr: {
// Required: Bundle @convex-dev/better-auth during SSR
noExternal: ["@convex-dev/better-auth"],
},
});
7. Router Configuration
src/router.tsx
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { QueryClient } from "@tanstack/react-query";
import { ConvexQueryClient } from "@convex-dev/react-query";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
export function createAppRouter() {
const queryClient = new QueryClient();
const convexQueryClient = new ConvexQueryClient(convex, { expectAuth: true });
queryClient.setDefaultOptions({
queries: { queryKeyHashFn: convexQueryClient.hashFn() },
});
convexQueryClient.connect(queryClient);
return createRouter({
routeTree,
context: { queryClient, convexQueryClient },
defaultPreload: "intent",
});
}
Environment Variables
.env.local
# Convex deployment (set by `npx convex dev`)
CONVEX_DEPLOYMENT=dev:your-deployment
VITE_CONVEX_URL=https://your-deployment.convex.cloud
VITE_CONVEX_SITE_URL=https://your-deployment.convex.site
# App URL
VITE_SITE_URL=http://localhost:3000
Convex environment variables (set via CLI):
# Generate and set secret
npx convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)
# Set site URL
npx convex env set SITE_URL http://localhost:3000
# OAuth (optional)
npx convex env set GITHUB_CLIENT_ID your-id
npx convex env set GITHUB_CLIENT_SECRET your-secret
Common Patterns
Protected Queries
// convex/todos.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { getUser, safeGetUser } from "./auth";
export const get = query({
handler: async (ctx) => {
const user = await safeGetUser(ctx);
if (!user) return [];
return ctx.db.query("todos").withIndex("userId", (q) => q.eq("userId", user._id)).collect();
},
});
export const create = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
const user = await getUser(ctx); // Throws if unauthenticated
await ctx.db.insert("todos", { text: args.text, userId: user._id, completed: false });
},
});
Sign In Component
import { authClient } from "~/lib/auth-client";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
export function SignIn() {
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
await authClient.signIn.email({
email: form.get("email") as string,
password: form.get("password") as string,
});
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<Input name="email" type="email" placeholder="Email" required />
<Input name="password" type="password" placeholder="Password" required />
<Button type="submit">Sign In</Button>
<Button type="button" variant="outline" onClick={() => authClient.signIn.social({ provider: "github" })}>
Sign in with GitHub
</Button>
</form>
);
}
Optimistic Updates
import { useMutation } from "@convex-dev/react-query";
import { api } from "convex/_generated/api";
const createTodo = useMutation(api.todos.create).withOptimisticUpdate(
(localStore, args) => {
const todos = localStore.getQuery(api.todos.get);
if (!todos) return;
localStore.setQuery(api.todos.get, {}, [
{ _id: crypto.randomUUID(), text: args.text, completed: false },
...todos,
]);
}
);
Auth Methods
Better-Auth supports multiple authentication methods:
// Email/Password
authClient.signIn.email({ email, password });
authClient.signUp.email({ email, password, name });
// OAuth
authClient.signIn.social({ provider: "github" });
authClient.signIn.social({ provider: "google" });
// Magic Link
authClient.signIn.magicLink({ email });
// Sign Out
authClient.signOut();
// Session
const { data: session } = authClient.useSession();
Commands
# Development
bun dev # Start frontend
bunx convex dev # Start Convex (separate terminal)
# Add Shadcn components
bunx shadcn@latest add button input card form dialog
# Add all Shadcn components
bunx shadcn@latest add --all
# Build & Deploy
bun run build
bunx convex deploy
SSR with TanStack Query
Use ensureQueryData and useSuspenseQuery for SSR:
// src/routes/_authed/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { convexQuery } from "@convex-dev/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { api } from "convex/_generated/api";
export const Route = createFileRoute("/_authed/")({
loader: async ({ context }) => {
await context.queryClient.ensureQueryData(convexQuery(api.todos.get, {}));
},
component: Dashboard,
});
function Dashboard() {
const { data: todos } = useSuspenseQuery(convexQuery(api.todos.get, {}));
return <TodoList todos={todos} />;
}
Sign Out (with expectAuth: true)
When using expectAuth: true, reload the page on sign out:
import { authClient } from "~/lib/auth-client";
function SignOutButton() {
const handleSignOut = async () => {
await authClient.signOut();
window.location.reload(); // Required with expectAuth: true
};
return <Button onClick={handleSignOut}>Sign Out</Button>;
}
TanStack Server Functions vs Convex: When to Use What
| Use Case | Use This | Why |
|---|---|---|
| Database CRUD | Convex query/mutation | Transactional, real-time |
| Real-time data | Convex query | Auto subscriptions |
| Third-party APIs | Convex action | Runs close to data |
| Auth handlers | TanStack server route | HTTP cookies/redirects |
| SSR preloading | TanStack loader | Pre-fetch for SSR |
| Webhooks | TanStack server route | HTTP endpoints |
Core principle: Convex = database & business logic. TanStack Start = HTTP & rendering.
See reference/server-functions-vs-convex.md for detailed guidelines.
Key Integration Points
| Integration | Key File | Purpose |
|---|---|---|
| Auth Client | src/lib/auth-client.tsx |
Client-side auth methods |
| Auth Server | src/lib/auth-server.ts |
SSR token handling |
| Root Provider | src/routes/__root.tsx |
Auth context + Convex client |
| Route Guard | src/routes/_authed.tsx |
Redirect unauthenticated users |
| Backend Auth | convex/auth.ts |
Better-Auth instance |
| HTTP Routes | convex/http.ts |
Auth endpoint registration |
| Database | convex/schema.ts |
Type-safe schema |
More from sebastiaanwouters/dotagents
flyctl
Deploy and manage apps on Fly.io using flyctl CLI. Triggers on: fly deploy, fly.io, flyctl, deploy to fly. Handles launch, deploy, scale, secrets, volumes, databases.
79teacher
Guide learning and deep understanding through proven methodologies (Socratic, Feynman, Problem-Based). Use when user says "help me understand", "teach me", "explain this", "learn about", "socratic", "feynman", "problem-based", "I don't understand", "confused about", "why does", or wants to truly grasp a concept.
77chef
Telegram communication for AI agents. ALL methods are BLOCKING. Use for user interviews, status updates, and feedback collection.
34bitwarden
Retrieves API keys, passwords, secrets from Bitwarden vault using bw CLI. Triggers on missing env variables, missing API keys, missing secrets, "secret not found", "env not set", or "use bw".
29librarian
Use for code research that needs dependency internals, upstream implementation examples, or external prior art. Always delegate to a subagent that investigates with opensrc and web search, then return only distilled findings, versions, paths, and links.
29frontend-design
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications. Generates creative, polished code that avoids generic AI aesthetics.
28