first commit
This commit is contained in:
109
.agent/skills/tanstack-start-best-practices/SKILL.md
Normal file
109
.agent/skills/tanstack-start-best-practices/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: tanstack-start-best-practices
|
||||
description: TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start.
|
||||
---
|
||||
|
||||
# TanStack Start Best Practices
|
||||
|
||||
Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications. These rules cover server functions, middleware, SSR, authentication, and deployment.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Creating server functions for data mutations
|
||||
- Setting up middleware for auth/logging
|
||||
- Configuring SSR and hydration
|
||||
- Implementing authentication flows
|
||||
- Handling errors across client/server boundary
|
||||
- Organizing full-stack code
|
||||
- Deploying to various platforms
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Rules | Impact |
|
||||
|----------|----------|-------|--------|
|
||||
| CRITICAL | Server Functions | 5 rules | Core data mutation patterns |
|
||||
| CRITICAL | Security | 4 rules | Prevents vulnerabilities |
|
||||
| HIGH | Middleware | 4 rules | Request/response handling |
|
||||
| HIGH | Authentication | 4 rules | Secure user sessions |
|
||||
| MEDIUM | API Routes | 1 rule | External endpoint patterns |
|
||||
| MEDIUM | SSR | 6 rules | Server rendering patterns |
|
||||
| MEDIUM | Error Handling | 3 rules | Graceful failure handling |
|
||||
| MEDIUM | Environment | 1 rule | Configuration management |
|
||||
| LOW | File Organization | 3 rules | Maintainable code structure |
|
||||
| LOW | Deployment | 2 rules | Production readiness |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Server Functions (Prefix: `sf-`)
|
||||
|
||||
- `sf-create-server-fn` — Use createServerFn for server-side logic
|
||||
- `sf-input-validation` — Always validate server function inputs
|
||||
- `sf-method-selection` — Choose appropriate HTTP method
|
||||
- `sf-error-handling` — Handle errors in server functions
|
||||
- `sf-response-headers` — Customize response headers when needed
|
||||
|
||||
### Security (Prefix: `sec-`)
|
||||
|
||||
- `sec-validate-inputs` — Validate all user inputs with schemas
|
||||
- `sec-auth-middleware` — Protect routes with auth middleware
|
||||
- `sec-sensitive-data` — Keep secrets server-side only
|
||||
- `sec-csrf-protection` — Implement CSRF protection for mutations
|
||||
|
||||
### Middleware (Prefix: `mw-`)
|
||||
|
||||
- `mw-request-middleware` — Use request middleware for cross-cutting concerns
|
||||
- `mw-function-middleware` — Use function middleware for server functions
|
||||
- `mw-context-flow` — Properly pass context through middleware
|
||||
- `mw-composability` — Compose middleware effectively
|
||||
|
||||
### Authentication (Prefix: `auth-`)
|
||||
|
||||
- `auth-session-management` — Implement secure session handling
|
||||
- `auth-route-protection` — Protect routes with beforeLoad
|
||||
- `auth-server-functions` — Verify auth in server functions
|
||||
- `auth-cookie-security` — Configure secure cookie settings
|
||||
|
||||
### API Routes (Prefix: `api-`)
|
||||
|
||||
- `api-routes` — Create API routes for external consumers
|
||||
|
||||
### SSR (Prefix: `ssr-`)
|
||||
|
||||
- `ssr-data-loading` — Load data appropriately for SSR
|
||||
- `ssr-hydration-safety` — Prevent hydration mismatches
|
||||
- `ssr-streaming` — Implement streaming SSR for faster TTFB
|
||||
- `ssr-selective` — Apply selective SSR when beneficial
|
||||
- `ssr-prerender` — Configure static prerendering and ISR
|
||||
|
||||
### Environment (Prefix: `env-`)
|
||||
|
||||
- `env-functions` — Use environment functions for configuration
|
||||
|
||||
### Error Handling (Prefix: `err-`)
|
||||
|
||||
- `err-server-errors` — Handle server function errors
|
||||
- `err-redirects` — Use redirects appropriately
|
||||
- `err-not-found` — Handle not-found scenarios
|
||||
|
||||
### File Organization (Prefix: `file-`)
|
||||
|
||||
- `file-separation` — Separate server and client code
|
||||
- `file-functions-file` — Use .functions.ts pattern
|
||||
- `file-shared-validation` — Share validation schemas
|
||||
|
||||
### Deployment (Prefix: `deploy-`)
|
||||
|
||||
- `deploy-env-config` — Configure environment variables
|
||||
- `deploy-adapters` — Choose appropriate deployment adapter
|
||||
|
||||
## How to Use
|
||||
|
||||
Each rule file in the `rules/` directory contains:
|
||||
1. **Explanation** — Why this pattern matters
|
||||
2. **Bad Example** — Anti-pattern to avoid
|
||||
3. **Good Example** — Recommended implementation
|
||||
4. **Context** — When to apply or skip this rule
|
||||
|
||||
## Full Reference
|
||||
|
||||
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||
238
.agent/skills/tanstack-start-best-practices/rules/api-routes.md
Normal file
238
.agent/skills/tanstack-start-best-practices/rules/api-routes.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# api-routes: Create Server Routes for External Consumers
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
While server functions are ideal for internal RPC, server routes provide traditional REST endpoints for external consumers, webhooks, and integrations. Use server routes when you need standard HTTP semantics, custom response formats, or third-party compatibility.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using server functions for webhook endpoints
|
||||
export const stripeWebhook = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ request }) => {
|
||||
// Server functions aren't designed for raw request handling
|
||||
// No easy access to raw body for signature verification
|
||||
// Response format is JSON by default
|
||||
})
|
||||
|
||||
// Or exposing internal functions to external consumers
|
||||
export const getUsers = createServerFn()
|
||||
.handler(async () => {
|
||||
return db.users.findMany()
|
||||
})
|
||||
// No versioning, no standard REST semantics
|
||||
```
|
||||
|
||||
## Good Example: Basic Server Route
|
||||
|
||||
```tsx
|
||||
// routes/api/users.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/users')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }) => {
|
||||
const users = await db.users.findMany({
|
||||
select: { id: true, name: true, email: true },
|
||||
})
|
||||
|
||||
return json(users, {
|
||||
headers: {
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
POST: async ({ request }) => {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate input
|
||||
const parsed = createUserSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const user = await db.users.create({ data: parsed.data })
|
||||
return json(user, { status: 201 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Webhook Handler
|
||||
|
||||
```tsx
|
||||
// routes/api/webhooks/stripe.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import Stripe from 'stripe'
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
|
||||
|
||||
export const Route = createFileRoute('/api/webhooks/stripe')({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
const signature = request.headers.get('stripe-signature')
|
||||
if (!signature) {
|
||||
return new Response('Missing signature', { status: 400 })
|
||||
}
|
||||
|
||||
// Get raw body for signature verification
|
||||
const rawBody = await request.text()
|
||||
|
||||
let event: Stripe.Event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return new Response('Invalid signature', { status: 400 })
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
await handleCheckoutComplete(event.data.object)
|
||||
break
|
||||
case 'customer.subscription.updated':
|
||||
await handleSubscriptionUpdate(event.data.object)
|
||||
break
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
|
||||
return new Response('OK', { status: 200 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: RESTful Resource with Dynamic Params
|
||||
|
||||
```tsx
|
||||
// routes/api/posts/$postId.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/posts/$postId')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ params }) => {
|
||||
const post = await db.posts.findUnique({
|
||||
where: { id: params.postId },
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
return json({ error: 'Post not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return json(post)
|
||||
},
|
||||
|
||||
PUT: async ({ request, params }) => {
|
||||
const body = await request.json()
|
||||
const parsed = updatePostSchema.safeParse(body)
|
||||
|
||||
if (!parsed.success) {
|
||||
return json({ error: parsed.error.flatten() }, { status: 400 })
|
||||
}
|
||||
|
||||
const post = await db.posts.update({
|
||||
where: { id: params.postId },
|
||||
data: parsed.data,
|
||||
})
|
||||
|
||||
return json(post)
|
||||
},
|
||||
|
||||
DELETE: async ({ params }) => {
|
||||
await db.posts.delete({ where: { id: params.postId } })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Route-Level Middleware
|
||||
|
||||
```tsx
|
||||
// routes/api/protected/data.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { apiKeyMiddleware } from '@/lib/middleware'
|
||||
|
||||
export const Route = createFileRoute('/api/protected/data')({
|
||||
server: {
|
||||
// Middleware applies to all handlers in this route
|
||||
middleware: [apiKeyMiddleware],
|
||||
handlers: {
|
||||
GET: async ({ request, context }) => {
|
||||
// context.client available from middleware
|
||||
const data = await fetchDataForClient(context.client.id)
|
||||
return json(data)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Using createHandlers for Handler-Specific Middleware
|
||||
|
||||
```tsx
|
||||
// routes/api/admin/users.ts
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
|
||||
export const Route = createFileRoute('/api/admin/users')({
|
||||
server: {
|
||||
middleware: [authMiddleware], // All handlers require auth
|
||||
handlers: (createHandlers) => ({
|
||||
GET: createHandlers.GET(async ({ context }) => {
|
||||
const users = await db.users.findMany()
|
||||
return json(users)
|
||||
}),
|
||||
|
||||
// DELETE requires additional admin middleware
|
||||
DELETE: createHandlers.DELETE({
|
||||
middleware: [adminOnlyMiddleware],
|
||||
handler: async ({ request, context }) => {
|
||||
const { userId } = await request.json()
|
||||
await db.users.delete({ where: { id: userId } })
|
||||
return json({ deleted: true })
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Server Functions vs Server Routes
|
||||
|
||||
| Feature | Server Functions | Server Routes |
|
||||
|---------|-----------------|--------------|
|
||||
| Primary use | Internal RPC | External consumers |
|
||||
| Type safety | Full end-to-end | Manual |
|
||||
| Response format | JSON (automatic) | Any (manual) |
|
||||
| Raw request access | Limited | Full |
|
||||
| URL structure | Auto-generated | Explicit paths |
|
||||
| Webhooks | Not ideal | Designed for |
|
||||
|
||||
## Context
|
||||
|
||||
- Server routes use `createFileRoute` with a `server.handlers` property
|
||||
- Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc.
|
||||
- Use `json()` helper for JSON responses
|
||||
- Return `Response` objects for custom formats
|
||||
- Handler receives `{ request, params }` object
|
||||
- Ideal for: webhooks, public APIs, file downloads, third-party integrations
|
||||
- Consider versioning: `/api/v1/users` for public APIs
|
||||
@@ -0,0 +1,192 @@
|
||||
# auth-route-protection: Protect Routes with beforeLoad
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Use `beforeLoad` in route definitions to check authentication before the route loads. This prevents unauthorized access, redirects to login, and can extend context with user data for child routes.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Checking auth in component - too late, data may have loaded
|
||||
function DashboardPage() {
|
||||
const user = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
navigate({ to: '/login' }) // Redirect after render
|
||||
}
|
||||
}, [user])
|
||||
|
||||
if (!user) return null // Flash of content possible
|
||||
|
||||
return <Dashboard user={user} />
|
||||
}
|
||||
|
||||
// No protection on route
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async () => {
|
||||
// Fetches sensitive data even for unauthenticated users
|
||||
return await fetchDashboardData()
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Route-Level Protection
|
||||
|
||||
```tsx
|
||||
// routes/_authenticated.tsx - Layout route for protected area
|
||||
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
|
||||
import { getSessionData } from '@/lib/session.server'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: async ({ location }) => {
|
||||
const session = await getSessionData()
|
||||
|
||||
if (!session) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: {
|
||||
redirect: location.href,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Extend context with user for all child routes
|
||||
return {
|
||||
user: session,
|
||||
}
|
||||
},
|
||||
component: AuthenticatedLayout,
|
||||
})
|
||||
|
||||
function AuthenticatedLayout() {
|
||||
return (
|
||||
<div>
|
||||
<AuthenticatedNav />
|
||||
<main>
|
||||
<Outlet /> {/* Child routes render here */}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// routes/_authenticated/dashboard.tsx
|
||||
// This route is automatically protected by parent
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
loader: async ({ context }) => {
|
||||
// context.user is guaranteed to exist
|
||||
return await fetchDashboardData(context.user.id)
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const data = Route.useLoaderData()
|
||||
const { user } = Route.useRouteContext()
|
||||
|
||||
return <Dashboard data={data} user={user} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Role-Based Access
|
||||
|
||||
```tsx
|
||||
// routes/_admin.tsx
|
||||
export const Route = createFileRoute('/_admin')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
// context.user comes from parent _authenticated route
|
||||
if (context.user.role !== 'admin') {
|
||||
throw redirect({ to: '/unauthorized' })
|
||||
}
|
||||
},
|
||||
component: AdminLayout,
|
||||
})
|
||||
|
||||
// File structure:
|
||||
// routes/
|
||||
// _authenticated.tsx # Requires login
|
||||
// _authenticated/
|
||||
// dashboard.tsx # /dashboard - any authenticated user
|
||||
// settings.tsx # /settings - any authenticated user
|
||||
// _admin.tsx # Admin layout
|
||||
// _admin/
|
||||
// users.tsx # /users - admin only
|
||||
// analytics.tsx # /analytics - admin only
|
||||
```
|
||||
|
||||
## Good Example: Preserving Redirect URL
|
||||
|
||||
```tsx
|
||||
// routes/login.tsx
|
||||
import { z } from 'zod'
|
||||
|
||||
export const Route = createFileRoute('/login')({
|
||||
validateSearch: z.object({
|
||||
redirect: z.string().optional(),
|
||||
}),
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
function LoginPage() {
|
||||
const { redirect } = Route.useSearch()
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: () => {
|
||||
// Redirect to original destination or default
|
||||
navigate({ to: redirect ?? '/dashboard' })
|
||||
},
|
||||
})
|
||||
|
||||
return <LoginForm onSubmit={loginMutation.mutate} />
|
||||
}
|
||||
|
||||
// In protected routes
|
||||
beforeLoad: async ({ location }) => {
|
||||
if (!session) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Conditional Content Based on Auth
|
||||
|
||||
```tsx
|
||||
// Public route with different content for logged-in users
|
||||
export const Route = createFileRoute('/')({
|
||||
beforeLoad: async () => {
|
||||
const session = await getSessionData()
|
||||
return { user: session?.user ?? null }
|
||||
},
|
||||
component: HomePage,
|
||||
})
|
||||
|
||||
function HomePage() {
|
||||
const { user } = Route.useRouteContext()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Hero />
|
||||
{user ? (
|
||||
<PersonalizedContent user={user} />
|
||||
) : (
|
||||
<SignUpCTA />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `beforeLoad` runs before route loading begins
|
||||
- Throwing `redirect()` prevents route from loading
|
||||
- Context from `beforeLoad` flows to loader and component
|
||||
- Child routes inherit parent's `beforeLoad` protection
|
||||
- Use pathless layout routes (`_authenticated.tsx`) for grouped protection
|
||||
- Store redirect URL in search params for post-login navigation
|
||||
@@ -0,0 +1,191 @@
|
||||
# auth-session-management: Implement Secure Session Handling
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Sessions maintain user authentication state across requests. Use HTTP-only cookies with secure settings to prevent XSS and CSRF attacks. Never store sensitive data in client-accessible storage.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Storing auth in localStorage - vulnerable to XSS
|
||||
function login(credentials: Credentials) {
|
||||
const token = await authenticate(credentials)
|
||||
localStorage.setItem('authToken', token) // XSS can steal this
|
||||
}
|
||||
|
||||
// Non-HTTP-only cookie - JavaScript accessible
|
||||
export const setSession = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }) => {
|
||||
setResponseHeader('Set-Cookie', `session=${data.token}`) // Not secure
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Secure Session Cookie
|
||||
|
||||
```tsx
|
||||
// lib/session.server.ts
|
||||
import { useSession } from '@tanstack/react-start/server'
|
||||
|
||||
// Configure session with secure defaults
|
||||
export function getSession() {
|
||||
return useSession({
|
||||
password: process.env.SESSION_SECRET!, // At least 32 characters
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true, // Not accessible via JavaScript
|
||||
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
|
||||
sameSite: 'lax', // CSRF protection
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Usage in server function
|
||||
export const login = createServerFn({ method: 'POST' })
|
||||
.validator(loginSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const session = await getSession()
|
||||
|
||||
// Verify credentials
|
||||
const user = await verifyCredentials(data.email, data.password)
|
||||
if (!user) {
|
||||
throw new Error('Invalid credentials')
|
||||
}
|
||||
|
||||
// Store only essential data in session
|
||||
await session.update({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Full Authentication Flow
|
||||
|
||||
```tsx
|
||||
// lib/auth.functions.ts
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { redirect } from '@tanstack/react-router'
|
||||
import { getSession } from './session.server'
|
||||
import { hashPassword, verifyPassword } from './password.server'
|
||||
|
||||
// Login
|
||||
export const login = createServerFn({ method: 'POST' })
|
||||
.validator(z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
}))
|
||||
.handler(async ({ data }) => {
|
||||
const user = await db.users.findUnique({
|
||||
where: { email: data.email },
|
||||
})
|
||||
|
||||
if (!user || !await verifyPassword(data.password, user.passwordHash)) {
|
||||
throw new Error('Invalid email or password')
|
||||
}
|
||||
|
||||
const session = await getSession()
|
||||
await session.update({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
})
|
||||
|
||||
throw redirect({ to: '/dashboard' })
|
||||
})
|
||||
|
||||
// Logout
|
||||
export const logout = createServerFn({ method: 'POST' })
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
await session.clear()
|
||||
throw redirect({ to: '/' })
|
||||
})
|
||||
|
||||
// Get current user
|
||||
export const getCurrentUser = createServerFn()
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
const data = await session.data
|
||||
|
||||
if (!data?.userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await db.users.findUnique({
|
||||
where: { id: data.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
// Don't include passwordHash!
|
||||
},
|
||||
})
|
||||
|
||||
return user
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Session with Role-Based Access
|
||||
|
||||
```tsx
|
||||
// lib/session.server.ts
|
||||
interface SessionData {
|
||||
userId: string
|
||||
email: string
|
||||
role: 'user' | 'admin'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export async function getSessionData(): Promise<SessionData | null> {
|
||||
const session = await getSession()
|
||||
const data = await session.data
|
||||
|
||||
if (!data?.userId) return null
|
||||
|
||||
// Validate session age
|
||||
const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
if (Date.now() - data.createdAt > maxAge) {
|
||||
await session.clear()
|
||||
return null
|
||||
}
|
||||
|
||||
return data as SessionData
|
||||
}
|
||||
|
||||
// Middleware for admin-only routes
|
||||
export const requireAdmin = createMiddleware()
|
||||
.server(async ({ next }) => {
|
||||
const session = await getSessionData()
|
||||
|
||||
if (!session || session.role !== 'admin') {
|
||||
throw redirect({ to: '/unauthorized' })
|
||||
}
|
||||
|
||||
return next({ context: { session } })
|
||||
})
|
||||
```
|
||||
|
||||
## Session Security Checklist
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| `httpOnly` | `true` | Prevents XSS from accessing cookie |
|
||||
| `secure` | `true` in prod | Requires HTTPS |
|
||||
| `sameSite` | `'lax'` or `'strict'` | CSRF protection |
|
||||
| `maxAge` | Application-specific | Session duration |
|
||||
| `password` | 32+ random chars | Encryption key |
|
||||
|
||||
## Context
|
||||
|
||||
- Always use HTTP-only cookies for session tokens
|
||||
- Generate `SESSION_SECRET` with `openssl rand -base64 32`
|
||||
- Store minimal data in session - fetch user details on demand
|
||||
- Implement session rotation on privilege changes
|
||||
- Consider session invalidation on password change
|
||||
- Use `sameSite: 'strict'` for highest CSRF protection
|
||||
@@ -0,0 +1,201 @@
|
||||
# deploy-adapters: Choose Appropriate Deployment Adapter
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
TanStack Start uses deployment adapters to target different hosting platforms. Each adapter optimizes the build output for its platform's runtime, edge functions, and static hosting capabilities.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Not configuring adapter - using defaults may not match your host
|
||||
// app.config.ts
|
||||
export default defineConfig({
|
||||
// No adapter specified
|
||||
// May not work correctly on your deployment platform
|
||||
})
|
||||
|
||||
// Or using wrong adapter for platform
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'node-server', // But deploying to Vercel Edge
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Vercel Deployment
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'vercel',
|
||||
// Vercel-specific options
|
||||
},
|
||||
})
|
||||
|
||||
// vercel.json (optional, for customization)
|
||||
{
|
||||
"framework": null,
|
||||
"buildCommand": "npm run build",
|
||||
"outputDirectory": ".output"
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Cloudflare Pages
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'cloudflare-pages',
|
||||
},
|
||||
})
|
||||
|
||||
// wrangler.toml
|
||||
name = "my-tanstack-app"
|
||||
compatibility_date = "2024-01-01"
|
||||
pages_build_output_dir = ".output/public"
|
||||
|
||||
// For Cloudflare Workers (full control)
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'cloudflare',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Netlify
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'netlify',
|
||||
},
|
||||
})
|
||||
|
||||
// netlify.toml
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = ".output/public"
|
||||
|
||||
[functions]
|
||||
directory = ".output/server"
|
||||
```
|
||||
|
||||
## Good Example: Node.js Server
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'node-server',
|
||||
// Optional: customize port
|
||||
},
|
||||
})
|
||||
|
||||
// Dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY .output .output
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
|
||||
// Or run directly
|
||||
// node .output/server/index.mjs
|
||||
```
|
||||
|
||||
## Good Example: Static Export (SPA)
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'static',
|
||||
prerender: {
|
||||
routes: ['/'],
|
||||
crawlLinks: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Output: .output/public (static files only)
|
||||
// Host anywhere: GitHub Pages, S3, any static host
|
||||
```
|
||||
|
||||
## Good Example: AWS Lambda
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'aws-lambda',
|
||||
},
|
||||
})
|
||||
|
||||
// Deploy with SST, Serverless Framework, or AWS CDK
|
||||
// serverless.yml example:
|
||||
service: my-tanstack-app
|
||||
provider:
|
||||
name: aws
|
||||
runtime: nodejs20.x
|
||||
functions:
|
||||
app:
|
||||
handler: .output/server/index.handler
|
||||
events:
|
||||
- http: ANY /
|
||||
- http: ANY /{proxy+}
|
||||
```
|
||||
|
||||
## Good Example: Bun Runtime
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
preset: 'bun',
|
||||
},
|
||||
})
|
||||
|
||||
// Run with: bun .output/server/index.mjs
|
||||
```
|
||||
|
||||
## Adapter Comparison
|
||||
|
||||
| Adapter | Runtime | Edge | Static | Best For |
|
||||
|---------|---------|------|--------|----------|
|
||||
| `vercel` | Node/Edge | Yes | Yes | Vercel hosting |
|
||||
| `cloudflare-pages` | Workers | Yes | Yes | Cloudflare Pages |
|
||||
| `cloudflare` | Workers | Yes | No | Cloudflare Workers |
|
||||
| `netlify` | Node | Yes | Yes | Netlify hosting |
|
||||
| `node-server` | Node | No | No | Docker, VPS, self-host |
|
||||
| `static` | None | No | Yes | Any static host |
|
||||
| `aws-lambda` | Node | No | No | AWS serverless |
|
||||
| `bun` | Bun | No | No | Bun runtime |
|
||||
|
||||
## Context
|
||||
|
||||
- Adapters transform output for target platform
|
||||
- Edge adapters have API limitations (no file system, etc.)
|
||||
- Static preset requires all routes to be prerenderable
|
||||
- Test locally with `npm run build && npm run preview`
|
||||
- Check platform docs for runtime-specific constraints
|
||||
- Some platforms auto-detect TanStack Start (no adapter needed)
|
||||
@@ -0,0 +1,211 @@
|
||||
# env-functions: Use Environment Functions for Configuration
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Environment functions provide type-safe access to environment variables on the server. They ensure secrets stay server-side, provide validation, and enable different configurations per environment (development, staging, production).
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Accessing env vars directly - no validation, potential leaks
|
||||
export const getApiData = createServerFn()
|
||||
.handler(async () => {
|
||||
// No validation - may be undefined
|
||||
const apiKey = process.env.API_KEY
|
||||
|
||||
// Accidentally exposed in error messages
|
||||
if (!apiKey) {
|
||||
throw new Error(`Missing API_KEY: ${process.env}`)
|
||||
}
|
||||
|
||||
return fetch(url, { headers: { Authorization: apiKey } })
|
||||
})
|
||||
|
||||
// Or importing env in shared files
|
||||
// lib/config.ts
|
||||
export const config = {
|
||||
apiKey: process.env.API_KEY, // Bundled into client!
|
||||
dbUrl: process.env.DATABASE_URL,
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Validated Environment Configuration
|
||||
|
||||
```tsx
|
||||
// lib/env.server.ts
|
||||
import { z } from 'zod'
|
||||
|
||||
const envSchema = z.object({
|
||||
// Required
|
||||
DATABASE_URL: z.string().url(),
|
||||
SESSION_SECRET: z.string().min(32),
|
||||
|
||||
// API Keys
|
||||
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
|
||||
|
||||
// Optional with defaults
|
||||
NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// Optional
|
||||
SENTRY_DSN: z.string().url().optional(),
|
||||
})
|
||||
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
||||
function validateEnv(): Env {
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment variables:')
|
||||
console.error(parsed.error.flatten().fieldErrors)
|
||||
throw new Error('Invalid environment configuration')
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
// Validate once at startup
|
||||
export const env = validateEnv()
|
||||
|
||||
// Usage in server functions
|
||||
export const getPaymentIntent = createServerFn({ method: 'POST' })
|
||||
.handler(async () => {
|
||||
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
|
||||
// Type-safe, validated access
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Public vs Private Config
|
||||
|
||||
```tsx
|
||||
// lib/env.server.ts - Server only (secrets)
|
||||
export const serverEnv = {
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
sessionSecret: process.env.SESSION_SECRET!,
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
|
||||
}
|
||||
|
||||
// lib/env.ts - Public config (safe for client)
|
||||
export const publicEnv = {
|
||||
appUrl: process.env.VITE_APP_URL ?? 'http://localhost:3000',
|
||||
stripePublicKey: process.env.VITE_STRIPE_PUBLIC_KEY!,
|
||||
sentryDsn: process.env.VITE_SENTRY_DSN,
|
||||
}
|
||||
|
||||
// Vite exposes VITE_ prefixed vars to client
|
||||
// Non-prefixed vars are server-only
|
||||
```
|
||||
|
||||
## Good Example: Environment-Specific Behavior
|
||||
|
||||
```tsx
|
||||
// lib/env.server.ts
|
||||
export const env = validateEnv()
|
||||
|
||||
export const isDevelopment = env.NODE_ENV === 'development'
|
||||
export const isProduction = env.NODE_ENV === 'production'
|
||||
export const isStaging = env.NODE_ENV === 'staging'
|
||||
|
||||
// lib/logger.server.ts
|
||||
import { env, isDevelopment } from './env.server'
|
||||
|
||||
export function log(level: string, message: string, data?: unknown) {
|
||||
if (isDevelopment) {
|
||||
console.log(`[${level}]`, message, data)
|
||||
return
|
||||
}
|
||||
|
||||
// Production: send to logging service
|
||||
if (env.SENTRY_DSN) {
|
||||
// Send to Sentry
|
||||
}
|
||||
}
|
||||
|
||||
// Server function with environment checks
|
||||
export const debugInfo = createServerFn()
|
||||
.handler(async () => {
|
||||
if (isProduction) {
|
||||
throw new Error('Debug endpoint not available in production')
|
||||
}
|
||||
|
||||
return {
|
||||
nodeVersion: process.version,
|
||||
env: env.NODE_ENV,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Feature Flags via Environment
|
||||
|
||||
```tsx
|
||||
// lib/features.server.ts
|
||||
import { env } from './env.server'
|
||||
|
||||
export const features = {
|
||||
newCheckout: env.FEATURE_NEW_CHECKOUT === 'true',
|
||||
betaDashboard: env.FEATURE_BETA_DASHBOARD === 'true',
|
||||
aiAssistant: env.FEATURE_AI_ASSISTANT === 'true',
|
||||
}
|
||||
|
||||
// Usage in server functions
|
||||
export const getCheckoutUrl = createServerFn()
|
||||
.handler(async () => {
|
||||
if (features.newCheckout) {
|
||||
return '/checkout/v2'
|
||||
}
|
||||
return '/checkout'
|
||||
})
|
||||
|
||||
// Usage in loaders
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async () => {
|
||||
return {
|
||||
showBetaFeatures: features.betaDashboard,
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Type-Safe env.d.ts
|
||||
|
||||
```tsx
|
||||
// env.d.ts - TypeScript declarations for env vars
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
// Required
|
||||
DATABASE_URL: string
|
||||
SESSION_SECRET: string
|
||||
|
||||
// Optional
|
||||
NODE_ENV?: 'development' | 'staging' | 'production'
|
||||
SENTRY_DSN?: string
|
||||
|
||||
// Vite public vars
|
||||
VITE_APP_URL?: string
|
||||
VITE_STRIPE_PUBLIC_KEY: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variable Checklist
|
||||
|
||||
| Variable | Prefix | Accessible On |
|
||||
|----------|--------|---------------|
|
||||
| `DATABASE_URL` | None | Server only |
|
||||
| `SESSION_SECRET` | None | Server only |
|
||||
| `STRIPE_SECRET_KEY` | None | Server only |
|
||||
| `VITE_APP_URL` | `VITE_` | Server + Client |
|
||||
| `VITE_STRIPE_PUBLIC_KEY` | `VITE_` | Server + Client |
|
||||
|
||||
## Context
|
||||
|
||||
- Never import `.server.ts` files in client code
|
||||
- Use `VITE_` prefix for client-accessible variables
|
||||
- Validate at startup to fail fast on misconfiguration
|
||||
- Use Zod or similar for runtime validation
|
||||
- Keep secrets out of error messages and logs
|
||||
- Consider using `.env.local` for local overrides (gitignored)
|
||||
@@ -0,0 +1,187 @@
|
||||
# err-server-errors: Handle Server Function Errors
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Server function errors cross the network boundary. Handle them gracefully with appropriate error types, status codes, and user-friendly messages. Avoid exposing internal details in production.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Throwing raw errors - exposes internals
|
||||
export const createUser = createServerFn({ method: 'POST' })
|
||||
.validator(createUserSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const user = await db.users.create({ data }) // May throw DB error
|
||||
return user
|
||||
// Prisma error with stack trace sent to client
|
||||
})
|
||||
|
||||
// Generic error handling - no useful info for client
|
||||
export const getPost = createServerFn()
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
return await fetchPost(data.id)
|
||||
} catch (e) {
|
||||
throw new Error('Something went wrong') // Too vague
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Structured Error Handling
|
||||
|
||||
```tsx
|
||||
// lib/errors.ts
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public status: number = 400
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string) {
|
||||
super(`${resource} not found`, 'NOT_FOUND', 404)
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor(message = 'Unauthorized') {
|
||||
super(message, 'UNAUTHORIZED', 401)
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, public fields?: Record<string, string>) {
|
||||
super(message, 'VALIDATION_ERROR', 400)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Server Function with Error Handling
|
||||
|
||||
```tsx
|
||||
import { createServerFn, notFound } from '@tanstack/react-start'
|
||||
import { setResponseStatus } from '@tanstack/react-start/server'
|
||||
|
||||
export const getPost = createServerFn()
|
||||
.validator(z.object({ id: z.string() }))
|
||||
.handler(async ({ data }) => {
|
||||
const post = await db.posts.findUnique({
|
||||
where: { id: data.id },
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
// Use built-in notFound for 404s
|
||||
throw notFound()
|
||||
}
|
||||
|
||||
return post
|
||||
})
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => {
|
||||
try {
|
||||
const post = await db.posts.create({ data })
|
||||
return post
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2002') {
|
||||
// Unique constraint violation
|
||||
setResponseStatus(409)
|
||||
throw new AppError('A post with this title already exists', 'DUPLICATE', 409)
|
||||
}
|
||||
}
|
||||
|
||||
// Log full error server-side
|
||||
console.error('Failed to create post:', error)
|
||||
|
||||
// Return sanitized error to client
|
||||
setResponseStatus(500)
|
||||
throw new AppError('Failed to create post', 'INTERNAL_ERROR', 500)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Client-Side Error Handling
|
||||
|
||||
```tsx
|
||||
function CreatePostForm() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createPost,
|
||||
onError: (error) => {
|
||||
if (error instanceof AppError) {
|
||||
setError(error.message)
|
||||
} else if (error instanceof ValidationError) {
|
||||
// Handle field-specific errors
|
||||
Object.entries(error.fields ?? {}).forEach(([field, message]) => {
|
||||
form.setError(field, { message })
|
||||
})
|
||||
} else {
|
||||
setError('An unexpected error occurred')
|
||||
}
|
||||
},
|
||||
onSuccess: (post) => {
|
||||
navigate({ to: '/posts/$postId', params: { postId: post.id } })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{/* form fields */}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Using Redirects for Auth Errors
|
||||
|
||||
```tsx
|
||||
export const updateProfile = createServerFn({ method: 'POST' })
|
||||
.validator(updateProfileSchema)
|
||||
.handler(async ({ data }) => {
|
||||
const session = await getSessionData()
|
||||
|
||||
if (!session) {
|
||||
// Redirect to login for auth errors
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: '/settings' },
|
||||
})
|
||||
}
|
||||
|
||||
return await db.users.update({
|
||||
where: { id: session.userId },
|
||||
data,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Error Response Best Practices
|
||||
|
||||
| Scenario | HTTP Status | Response |
|
||||
|----------|-------------|----------|
|
||||
| Validation failed | 400 | Field-specific errors |
|
||||
| Not authenticated | 401 | Redirect to login |
|
||||
| Not authorized | 403 | Generic forbidden message |
|
||||
| Resource not found | 404 | Use `notFound()` |
|
||||
| Conflict (duplicate) | 409 | Specific conflict message |
|
||||
| Server error | 500 | Generic message, log details |
|
||||
|
||||
## Context
|
||||
|
||||
- Use `notFound()` for 404 errors - integrates with router
|
||||
- Use `redirect()` for auth-related errors
|
||||
- Set status codes with `setResponseStatus()`
|
||||
- Log full errors server-side, sanitize for client
|
||||
- Create custom error classes for consistent handling
|
||||
- Validation errors from `.validator()` are automatic
|
||||
@@ -0,0 +1,152 @@
|
||||
# file-separation: Separate Server and Client Code
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Organize code by execution context to prevent server code from accidentally bundling into client builds. Use `.server.ts` for server-only code, `.functions.ts` for server function definitions, and standard `.ts` for shared code.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// lib/posts.ts - Mixed server and client code
|
||||
import { db } from './db' // Database - server only
|
||||
import { formatDate } from './utils' // Utility - shared
|
||||
|
||||
export async function getPosts() {
|
||||
// This uses db, so it's server-only
|
||||
// But file might be imported on client
|
||||
return db.posts.findMany()
|
||||
}
|
||||
|
||||
export function formatPostDate(date: Date) {
|
||||
// This could run anywhere
|
||||
return formatDate(date)
|
||||
}
|
||||
|
||||
// routes/posts.tsx
|
||||
import { getPosts, formatPostDate } from '@/lib/posts'
|
||||
// Importing getPosts pulls db into client bundle (error or bloat)
|
||||
```
|
||||
|
||||
## Good Example: Clear Separation
|
||||
|
||||
```
|
||||
lib/
|
||||
├── posts.ts # Shared types and utilities
|
||||
├── posts.server.ts # Server-only database logic
|
||||
├── posts.functions.ts # Server function definitions
|
||||
└── schemas/
|
||||
└── post.ts # Shared validation schemas
|
||||
```
|
||||
|
||||
```tsx
|
||||
// lib/posts.ts - Shared (safe to import anywhere)
|
||||
export interface Post {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export function formatPostDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// lib/posts.server.ts - Server only (never import on client)
|
||||
import { db } from './db'
|
||||
import type { Post } from './posts'
|
||||
|
||||
export async function getPostsFromDb(): Promise<Post[]> {
|
||||
return db.posts.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
}
|
||||
|
||||
export async function createPostInDb(data: CreatePostInput): Promise<Post> {
|
||||
return db.posts.create({ data })
|
||||
}
|
||||
|
||||
// lib/posts.functions.ts - Server functions (safe to import anywhere)
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { getPostsFromDb, createPostInDb } from './posts.server'
|
||||
import { createPostSchema } from './schemas/post'
|
||||
|
||||
export const getPosts = createServerFn()
|
||||
.handler(async () => {
|
||||
return await getPostsFromDb()
|
||||
})
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => {
|
||||
return await createPostInDb(data)
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Using in Components
|
||||
|
||||
```tsx
|
||||
// components/PostList.tsx
|
||||
import { getPosts } from '@/lib/posts.functions' // Safe - RPC stub on client
|
||||
import { formatPostDate } from '@/lib/posts' // Safe - shared utility
|
||||
import type { Post } from '@/lib/posts' // Safe - type only
|
||||
|
||||
function PostList() {
|
||||
const postsQuery = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: () => getPosts(), // Calls server function
|
||||
})
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{postsQuery.data?.map((post) => (
|
||||
<li key={post.id}>
|
||||
{post.title}
|
||||
<span>{formatPostDate(post.createdAt)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## File Convention Summary
|
||||
|
||||
| Suffix | Purpose | Safe to Import on Client |
|
||||
|--------|---------|-------------------------|
|
||||
| `.ts` | Shared utilities, types | Yes |
|
||||
| `.server.ts` | Server-only logic (db, secrets) | No |
|
||||
| `.functions.ts` | Server function wrappers | Yes |
|
||||
| `.client.ts` | Client-only code | Yes (client only) |
|
||||
|
||||
## Good Example: Environment Variables
|
||||
|
||||
```tsx
|
||||
// lib/config.server.ts - Server secrets
|
||||
export const config = {
|
||||
databaseUrl: process.env.DATABASE_URL!,
|
||||
sessionSecret: process.env.SESSION_SECRET!,
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
|
||||
}
|
||||
|
||||
// lib/config.ts - Public config (safe for client)
|
||||
export const publicConfig = {
|
||||
appName: 'My App',
|
||||
apiUrl: process.env.NEXT_PUBLIC_API_URL,
|
||||
stripePublicKey: process.env.NEXT_PUBLIC_STRIPE_KEY,
|
||||
}
|
||||
|
||||
// Never import config.server.ts on client
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `.server.ts` files should never be directly imported in client code
|
||||
- Server functions in `.functions.ts` are safe - build replaces with RPC
|
||||
- Types from `.server.ts` are safe if using `import type`
|
||||
- TanStack Start's build process validates proper separation
|
||||
- This pattern enables tree-shaking and smaller client bundles
|
||||
- Use consistent naming convention across your team
|
||||
@@ -0,0 +1,166 @@
|
||||
# mw-request-middleware: Use Request Middleware for Cross-Cutting Concerns
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Request middleware runs before every server request (routes, SSR, server functions). Use it for authentication, logging, rate limiting, and other cross-cutting concerns that apply globally.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Duplicating auth logic in every server function
|
||||
export const getProfile = createServerFn()
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
if (!session) throw new Error('Unauthorized')
|
||||
// ... rest of handler
|
||||
})
|
||||
|
||||
export const updateProfile = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }) => {
|
||||
const session = await getSession()
|
||||
if (!session) throw new Error('Unauthorized')
|
||||
// ... rest of handler
|
||||
})
|
||||
|
||||
export const deleteAccount = createServerFn({ method: 'POST' })
|
||||
.handler(async () => {
|
||||
const session = await getSession()
|
||||
if (!session) throw new Error('Unauthorized')
|
||||
// ... rest of handler
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Authentication Middleware
|
||||
|
||||
```tsx
|
||||
// lib/middleware/auth.ts
|
||||
import { createMiddleware } from '@tanstack/react-start'
|
||||
import { getSession } from './session.server'
|
||||
|
||||
export const authMiddleware = createMiddleware()
|
||||
.server(async ({ next }) => {
|
||||
const session = await getSession()
|
||||
|
||||
// Pass session to downstream handlers via context
|
||||
return next({
|
||||
context: {
|
||||
session,
|
||||
user: session?.user ?? null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// lib/middleware/requireAuth.ts
|
||||
export const requireAuthMiddleware = createMiddleware()
|
||||
.middleware([authMiddleware]) // Depends on auth middleware
|
||||
.server(async ({ next, context }) => {
|
||||
if (!context.user) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
user: context.user, // Now guaranteed to exist
|
||||
},
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Logging Middleware
|
||||
|
||||
```tsx
|
||||
// lib/middleware/logging.ts
|
||||
export const loggingMiddleware = createMiddleware()
|
||||
.server(async ({ next, request }) => {
|
||||
const start = Date.now()
|
||||
const requestId = crypto.randomUUID()
|
||||
|
||||
console.log(`[${requestId}] ${request.method} ${request.url}`)
|
||||
|
||||
try {
|
||||
const result = await next({
|
||||
context: { requestId },
|
||||
})
|
||||
|
||||
console.log(`[${requestId}] Completed in ${Date.now() - start}ms`)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error(`[${requestId}] Error:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Global Middleware Configuration
|
||||
|
||||
```tsx
|
||||
// app/start.ts
|
||||
import { createStart } from '@tanstack/react-start/server'
|
||||
import { loggingMiddleware } from './middleware/logging'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
|
||||
export default createStart({
|
||||
// Request middleware runs for all requests
|
||||
requestMiddleware: [
|
||||
loggingMiddleware,
|
||||
authMiddleware,
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Rate Limiting Middleware
|
||||
|
||||
```tsx
|
||||
// lib/middleware/rateLimit.ts
|
||||
import { createMiddleware } from '@tanstack/react-start'
|
||||
|
||||
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
|
||||
|
||||
export const rateLimitMiddleware = createMiddleware()
|
||||
.server(async ({ next, request }) => {
|
||||
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
|
||||
const now = Date.now()
|
||||
const windowMs = 60 * 1000 // 1 minute
|
||||
const maxRequests = 100
|
||||
|
||||
let record = rateLimitStore.get(ip)
|
||||
|
||||
if (!record || record.resetAt < now) {
|
||||
record = { count: 0, resetAt: now + windowMs }
|
||||
}
|
||||
|
||||
record.count++
|
||||
rateLimitStore.set(ip, record)
|
||||
|
||||
if (record.count > maxRequests) {
|
||||
throw new Response('Too Many Requests', { status: 429 })
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
```
|
||||
|
||||
## Middleware Execution Order
|
||||
|
||||
```
|
||||
Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response
|
||||
|
||||
// Example with timing:
|
||||
loggingMiddleware.server(async ({ next }) => {
|
||||
console.log('Before handler')
|
||||
const result = await next() // Calls next middleware/handler
|
||||
console.log('After handler')
|
||||
return result
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Request middleware applies to all server requests
|
||||
- Middleware can add to context using `next({ context: {...} })`
|
||||
- Order matters - first middleware wraps the entire chain
|
||||
- Global middleware defined in `app/start.ts`
|
||||
- Route-specific middleware uses `beforeLoad`
|
||||
- Server function middleware uses separate pattern (see `mw-function-middleware`)
|
||||
@@ -0,0 +1,146 @@
|
||||
# sf-create-server-fn: Use createServerFn for Server-Side Logic
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
`createServerFn()` creates type-safe server functions that can be called from anywhere - loaders, components, or other server functions. The code inside the handler runs only on the server, with automatic RPC for client calls.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using fetch directly - no type safety, manual serialization
|
||||
async function createPost(data: CreatePostInput) {
|
||||
const response = await fetch('/api/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create post')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Or using API routes - more boilerplate
|
||||
// api/posts.ts
|
||||
export async function POST(request: Request) {
|
||||
const data = await request.json()
|
||||
// No type safety from client
|
||||
const post = await db.posts.create({ data })
|
||||
return new Response(JSON.stringify(post))
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// lib/posts.functions.ts
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
import { db } from './db.server'
|
||||
|
||||
const createPostSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
published: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// This code only runs on the server
|
||||
const post = await db.posts.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
published: data.published,
|
||||
},
|
||||
})
|
||||
return post
|
||||
})
|
||||
|
||||
// Usage in component
|
||||
function CreatePostForm() {
|
||||
const createPostMutation = useServerFn(createPost)
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
try {
|
||||
const post = await createPostMutation({
|
||||
data: {
|
||||
title: formData.get('title') as string,
|
||||
content: formData.get('content') as string,
|
||||
published: false,
|
||||
},
|
||||
})
|
||||
// post is fully typed
|
||||
console.log('Created post:', post.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to create post:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: GET Function for Data Fetching
|
||||
|
||||
```tsx
|
||||
// lib/posts.functions.ts
|
||||
export const getPosts = createServerFn() // GET is default
|
||||
.handler(async () => {
|
||||
const posts = await db.posts.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
})
|
||||
return posts
|
||||
})
|
||||
|
||||
export const getPost = createServerFn()
|
||||
.validator(z.object({ id: z.string() }))
|
||||
.handler(async ({ data }) => {
|
||||
const post = await db.posts.findUnique({
|
||||
where: { id: data.id },
|
||||
})
|
||||
if (!post) {
|
||||
throw notFound()
|
||||
}
|
||||
return post
|
||||
})
|
||||
|
||||
// Usage in route loader
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
return await getPost({ data: { id: params.postId } })
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Context and Dependencies
|
||||
|
||||
```tsx
|
||||
// Compose server functions
|
||||
export const getPostWithComments = createServerFn()
|
||||
.validator(z.object({ postId: z.string() }))
|
||||
.handler(async ({ data }) => {
|
||||
const [post, comments] = await Promise.all([
|
||||
getPost({ data: { id: data.postId } }),
|
||||
getComments({ data: { postId: data.postId } }),
|
||||
])
|
||||
|
||||
return { post, comments }
|
||||
})
|
||||
```
|
||||
|
||||
## Key Benefits
|
||||
|
||||
- **Type safety**: Input/output types flow through client and server
|
||||
- **Automatic serialization**: No manual JSON parsing
|
||||
- **Code splitting**: Server code never reaches client bundle
|
||||
- **Composable**: Call from loaders, components, or other server functions
|
||||
- **Validation**: Built-in input validation with schema libraries
|
||||
|
||||
## Context
|
||||
|
||||
- Default method is GET (idempotent, cacheable)
|
||||
- Use POST for mutations that change data
|
||||
- Server functions are RPC calls under the hood
|
||||
- Validation errors are properly typed and serialized
|
||||
- Import is safe on client - build process replaces with RPC stub
|
||||
@@ -0,0 +1,158 @@
|
||||
# sf-input-validation: Always Validate Server Function Inputs
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No validation - trusting client input directly
|
||||
export const updateUser = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }) => {
|
||||
// data is unknown/any - no type safety
|
||||
// SQL injection, invalid data, type errors all possible
|
||||
await db.users.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
role: data.role, // Could be set to 'admin' by malicious client!
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Weak validation - type assertion without runtime check
|
||||
export const deletePost = createServerFn({ method: 'POST' })
|
||||
.handler(async ({ data }: { data: { id: string } }) => {
|
||||
// Type assertion doesn't validate at runtime
|
||||
await db.posts.delete({ where: { id: data.id } })
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Zod Validation
|
||||
|
||||
```tsx
|
||||
import { createServerFn } from '@tanstack/react-start'
|
||||
import { z } from 'zod'
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
// Don't allow role updates from client input!
|
||||
})
|
||||
|
||||
export const updateUser = createServerFn({ method: 'POST' })
|
||||
.validator(updateUserSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// data is fully typed: { id: string; name: string; email: string }
|
||||
const user = await db.users.update({
|
||||
where: { id: data.id },
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
},
|
||||
})
|
||||
return user
|
||||
})
|
||||
|
||||
// Validation errors are automatically returned to client
|
||||
// with proper status codes and messages
|
||||
```
|
||||
|
||||
## Good Example: Complex Validation
|
||||
|
||||
```tsx
|
||||
const createOrderSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
productId: z.string().uuid(),
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
})).min(1).max(50),
|
||||
shippingAddress: z.object({
|
||||
street: z.string().min(1),
|
||||
city: z.string().min(1),
|
||||
state: z.string().length(2),
|
||||
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
|
||||
}),
|
||||
couponCode: z.string().optional(),
|
||||
})
|
||||
|
||||
export const createOrder = createServerFn({ method: 'POST' })
|
||||
.validator(createOrderSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// All data is validated and typed
|
||||
// Process order safely
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Transform and Refine
|
||||
|
||||
```tsx
|
||||
const registrationSchema = z.object({
|
||||
email: z.string().email().toLowerCase(), // Transform to lowercase
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine(
|
||||
(data) => data.password === data.confirmPassword,
|
||||
{ message: 'Passwords must match', path: ['confirmPassword'] }
|
||||
)
|
||||
|
||||
export const register = createServerFn({ method: 'POST' })
|
||||
.validator(registrationSchema)
|
||||
.handler(async ({ data }) => {
|
||||
// Passwords match, email is lowercase
|
||||
// Only password needed (confirmPassword was for validation)
|
||||
const hashedPassword = await hashPassword(data.password)
|
||||
return await createUser({
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Sharing Schemas Between Client and Server
|
||||
|
||||
```tsx
|
||||
// lib/schemas/post.ts - Shared validation schema
|
||||
import { z } from 'zod'
|
||||
|
||||
export const createPostSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
content: z.string().min(1),
|
||||
tags: z.array(z.string()).max(10).optional(),
|
||||
})
|
||||
|
||||
export type CreatePostInput = z.infer<typeof createPostSchema>
|
||||
|
||||
// lib/posts.functions.ts - Server function
|
||||
import { createPostSchema } from './schemas/post'
|
||||
|
||||
export const createPost = createServerFn({ method: 'POST' })
|
||||
.validator(createPostSchema)
|
||||
.handler(async ({ data }) => { /* ... */ })
|
||||
|
||||
// components/CreatePostForm.tsx - Client form validation
|
||||
import { createPostSchema, type CreatePostInput } from '@/lib/schemas/post'
|
||||
|
||||
function CreatePostForm() {
|
||||
const form = useForm<CreatePostInput>({
|
||||
resolver: zodResolver(createPostSchema),
|
||||
})
|
||||
// Same validation client and server side
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Network boundary = trust boundary - always validate
|
||||
- Use `.validator()` before `.handler()` in the chain
|
||||
- Validation errors return proper HTTP status codes
|
||||
- Share schemas between client forms and server functions
|
||||
- Strip or ignore fields clients shouldn't control (like `role`, `isAdmin`)
|
||||
- Consider rate limiting for mutation endpoints
|
||||
@@ -0,0 +1,187 @@
|
||||
# ssr-hydration-safety: Prevent Hydration Mismatches
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Hydration errors occur when server-rendered HTML doesn't match what the client expects. This causes React to discard server HTML and re-render, losing SSR benefits. Ensure consistent rendering between server and client.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using Date.now() - different on server and client
|
||||
function Timestamp() {
|
||||
return <span>Generated at: {Date.now()}</span>
|
||||
}
|
||||
|
||||
// Using Math.random() - always different
|
||||
function RandomGreeting() {
|
||||
const greetings = ['Hello', 'Hi', 'Hey']
|
||||
return <h1>{greetings[Math.floor(Math.random() * 3)]}</h1>
|
||||
}
|
||||
|
||||
// Checking window - doesn't exist on server
|
||||
function DeviceInfo() {
|
||||
return <span>Width: {window.innerWidth}px</span> // Error on server
|
||||
}
|
||||
|
||||
// Conditional render based on time
|
||||
function TimeBasedContent() {
|
||||
const hour = new Date().getHours()
|
||||
return hour < 12 ? <Morning /> : <Evening />
|
||||
// Server might render Morning, client renders Evening
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Consistent Server/Client Rendering
|
||||
|
||||
```tsx
|
||||
// Pass data from server to avoid mismatch
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async () => {
|
||||
return {
|
||||
generatedAt: Date.now(),
|
||||
}
|
||||
},
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
function Dashboard() {
|
||||
const { generatedAt } = Route.useLoaderData()
|
||||
// Both server and client use same value
|
||||
return <span>Generated at: {generatedAt}</span>
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Client-Only Components
|
||||
|
||||
```tsx
|
||||
// Use lazy loading for client-only features
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const ClientOnlyMap = lazy(() => import('./Map'))
|
||||
|
||||
function LocationPage() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Our Location</h1>
|
||||
<Suspense fallback={<MapPlaceholder />}>
|
||||
<ClientOnlyMap />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Or use useEffect for client-only state
|
||||
function WindowSize() {
|
||||
const [size, setSize] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!size) {
|
||||
return <span>Loading dimensions...</span>
|
||||
}
|
||||
|
||||
return <span>{size.width} x {size.height}</span>
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Stable Random Values
|
||||
|
||||
```tsx
|
||||
// Generate random value on server, pass to client
|
||||
export const Route = createFileRoute('/onboarding')({
|
||||
loader: () => ({
|
||||
welcomeVariant: Math.floor(Math.random() * 3),
|
||||
}),
|
||||
component: Onboarding,
|
||||
})
|
||||
|
||||
function Onboarding() {
|
||||
const { welcomeVariant } = Route.useLoaderData()
|
||||
const messages = ['Welcome aboard!', 'Let's get started!', 'Great to have you!']
|
||||
|
||||
return <h1>{messages[welcomeVariant]}</h1> // Same on server and client
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Handling Time Zones
|
||||
|
||||
```tsx
|
||||
// Pass formatted date from server
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await getPost(params.postId)
|
||||
return {
|
||||
...post,
|
||||
// Format on server to avoid timezone mismatch
|
||||
formattedDate: new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
timeZone: 'UTC', // Consistent timezone
|
||||
}).format(post.createdAt),
|
||||
}
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
// Or use client-only formatting
|
||||
function RelativeTime({ date }: { date: Date }) {
|
||||
const [formatted, setFormatted] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
// Format in user's timezone after hydration
|
||||
setFormatted(formatDistanceToNow(date, { addSuffix: true }))
|
||||
}, [date])
|
||||
|
||||
// Show absolute date initially (same server/client)
|
||||
return <time dateTime={date.toISOString()}>
|
||||
{formatted || date.toISOString().split('T')[0]}
|
||||
</time>
|
||||
}
|
||||
```
|
||||
|
||||
## Common Hydration Mismatch Causes
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `Date.now()` / `new Date()` | Pass timestamp from loader |
|
||||
| `Math.random()` | Generate on server, pass to client |
|
||||
| `window` / `document` | Use useEffect or lazy loading |
|
||||
| User timezone differences | Use UTC or client-only formatting |
|
||||
| Browser-specific APIs | Check `typeof window !== 'undefined'` |
|
||||
| Extension-injected content | Use `suppressHydrationWarning` |
|
||||
|
||||
## Debugging Hydration Errors
|
||||
|
||||
```tsx
|
||||
// React 18+ provides detailed hydration error messages
|
||||
// Check the console for:
|
||||
// - "Text content does not match"
|
||||
// - "Hydration failed because"
|
||||
// - The specific DOM element causing the issue
|
||||
|
||||
// For difficult cases, use suppressHydrationWarning sparingly
|
||||
function UserContent({ html }: { html: string }) {
|
||||
return (
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Hydration compares server HTML with client render
|
||||
- Mismatches force full client re-render (slow, flash)
|
||||
- Use loaders to pass dynamic data consistently
|
||||
- Defer client-only content with useEffect or Suspense
|
||||
- Test SSR by disabling JavaScript and checking render
|
||||
- Development mode shows hydration warnings in console
|
||||
@@ -0,0 +1,199 @@
|
||||
# ssr-prerender: Configure Static Prerendering and ISR
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Static prerendering generates HTML at build time for pages that don't require request-time data. Incremental Static Regeneration (ISR) extends this by revalidating cached pages on a schedule. Use these for better performance and lower server costs.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// SSR for completely static content - wasteful
|
||||
export const Route = createFileRoute('/about')({
|
||||
loader: async () => {
|
||||
// Fetching static content on every request
|
||||
const content = await fetchAboutPageContent()
|
||||
return { content }
|
||||
},
|
||||
})
|
||||
|
||||
// Or no caching headers for semi-static content
|
||||
export const Route = createFileRoute('/blog/$slug')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.slug)
|
||||
return { post }
|
||||
// Every request hits the database
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Static Prerendering
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
import { defineConfig } from '@tanstack/react-start/config'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
prerender: {
|
||||
// Routes to prerender at build time
|
||||
routes: [
|
||||
'/',
|
||||
'/about',
|
||||
'/contact',
|
||||
'/pricing',
|
||||
],
|
||||
// Or crawl from root
|
||||
crawlLinks: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// routes/about.tsx - Will be prerendered
|
||||
export const Route = createFileRoute('/about')({
|
||||
loader: async () => {
|
||||
// Runs at BUILD time, not request time
|
||||
const content = await fetchAboutPageContent()
|
||||
return { content }
|
||||
},
|
||||
component: AboutPage,
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Dynamic Prerendering
|
||||
|
||||
```tsx
|
||||
// app.config.ts
|
||||
export default defineConfig({
|
||||
server: {
|
||||
prerender: {
|
||||
// Generate routes dynamically
|
||||
routes: async () => {
|
||||
const posts = await db.posts.findMany({
|
||||
where: { published: true },
|
||||
select: { slug: true },
|
||||
})
|
||||
|
||||
return [
|
||||
'/',
|
||||
'/blog',
|
||||
...posts.map(p => `/blog/${p.slug}`),
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: ISR with Revalidation
|
||||
|
||||
```tsx
|
||||
// routes/blog/$slug.tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { setHeaders } from '@tanstack/react-start/server'
|
||||
|
||||
export const Route = createFileRoute('/blog/$slug')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.slug)
|
||||
|
||||
// ISR: Cache for 60 seconds, then revalidate
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
|
||||
})
|
||||
|
||||
return { post }
|
||||
},
|
||||
component: BlogPost,
|
||||
})
|
||||
|
||||
// First request: SSR and cache
|
||||
// Next 60 seconds: Serve cached version
|
||||
// After 60 seconds: Serve stale, revalidate in background
|
||||
// After 300 seconds: Full SSR again
|
||||
```
|
||||
|
||||
## Good Example: Hybrid Static/Dynamic
|
||||
|
||||
```tsx
|
||||
// routes/products.tsx - Prerendered
|
||||
export const Route = createFileRoute('/products')({
|
||||
loader: async () => {
|
||||
// Featured products - prerendered at build
|
||||
const featured = await fetchFeaturedProducts()
|
||||
return { featured }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/products/$productId.tsx - ISR
|
||||
export const Route = createFileRoute('/products/$productId')({
|
||||
loader: async ({ params }) => {
|
||||
const product = await fetchProduct(params.productId)
|
||||
|
||||
if (!product) throw notFound()
|
||||
|
||||
// Cache product pages for 5 minutes
|
||||
setHeaders({
|
||||
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600',
|
||||
})
|
||||
|
||||
return { product }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/cart.tsx - Always SSR (user-specific)
|
||||
export const Route = createFileRoute('/cart')({
|
||||
loader: async ({ context }) => {
|
||||
// No caching - user-specific data
|
||||
setHeaders({
|
||||
'Cache-Control': 'private, no-store',
|
||||
})
|
||||
|
||||
const cart = await fetchUserCart(context.user.id)
|
||||
return { cart }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: On-Demand Revalidation
|
||||
|
||||
```tsx
|
||||
// API route to trigger revalidation
|
||||
// app/routes/api/revalidate.ts
|
||||
export const APIRoute = createAPIFileRoute('/api/revalidate')({
|
||||
POST: async ({ request }) => {
|
||||
const { secret, path } = await request.json()
|
||||
|
||||
// Verify secret
|
||||
if (secret !== process.env.REVALIDATE_SECRET) {
|
||||
return json({ error: 'Invalid secret' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Trigger revalidation (implementation depends on hosting)
|
||||
await revalidatePath(path)
|
||||
|
||||
return json({ revalidated: true, path })
|
||||
},
|
||||
})
|
||||
|
||||
// Usage: POST /api/revalidate { "secret": "...", "path": "/blog/my-post" }
|
||||
```
|
||||
|
||||
## Cache-Control Directives
|
||||
|
||||
| Directive | Meaning |
|
||||
|-----------|---------|
|
||||
| `s-maxage=N` | CDN cache duration (seconds) |
|
||||
| `max-age=N` | Browser cache duration |
|
||||
| `stale-while-revalidate=N` | Serve stale while fetching fresh |
|
||||
| `private` | Don't cache on CDN (user-specific) |
|
||||
| `no-store` | Never cache |
|
||||
|
||||
## Context
|
||||
|
||||
- Prerendering happens at build time - no request context
|
||||
- ISR requires CDN/edge support (Vercel, Cloudflare, etc.)
|
||||
- Use prerendering for truly static pages (about, pricing)
|
||||
- Use ISR for content that changes but not per-request
|
||||
- Always SSR for user-specific or real-time data
|
||||
- Test with production builds - dev server is always SSR
|
||||
@@ -0,0 +1,201 @@
|
||||
# ssr-streaming: Implement Streaming SSR for Faster TTFB
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Streaming SSR sends HTML chunks to the browser as they're ready, rather than waiting for all data to load. This improves Time to First Byte (TTFB) and perceived performance by showing content progressively.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Blocking SSR - waits for everything
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// All of these must complete before ANY HTML is sent
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(userQueries.profile()), // 200ms
|
||||
queryClient.ensureQueryData(dashboardQueries.stats()), // 500ms
|
||||
queryClient.ensureQueryData(activityQueries.recent()), // 300ms
|
||||
queryClient.ensureQueryData(notificationQueries.all()), // 400ms
|
||||
])
|
||||
// TTFB: 500ms (slowest query)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Stream Non-Critical Content
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// Only await critical above-the-fold data
|
||||
await queryClient.ensureQueryData(userQueries.profile())
|
||||
|
||||
// Start fetching but don't await
|
||||
queryClient.prefetchQuery(dashboardQueries.stats())
|
||||
queryClient.prefetchQuery(activityQueries.recent())
|
||||
queryClient.prefetchQuery(notificationQueries.all())
|
||||
|
||||
// HTML starts streaming immediately after profile loads
|
||||
// TTFB: 200ms
|
||||
},
|
||||
component: DashboardPage,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
// Critical data - ready immediately (from loader)
|
||||
const { data: user } = useSuspenseQuery(userQueries.profile())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header user={user} />
|
||||
|
||||
{/* Non-critical - streams in with Suspense */}
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<ActivitySkeleton />}>
|
||||
<RecentActivity />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<NotificationsSkeleton />}>
|
||||
<NotificationsList />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Each section loads independently and streams when ready
|
||||
function DashboardStats() {
|
||||
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
|
||||
return <StatsDisplay stats={stats} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Nested Suspense Boundaries
|
||||
|
||||
```tsx
|
||||
function DashboardPage() {
|
||||
const { data: user } = useSuspenseQuery(userQueries.profile())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header user={user} />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left column streams together */}
|
||||
<Suspense fallback={<LeftColumnSkeleton />}>
|
||||
<LeftColumn />
|
||||
</Suspense>
|
||||
|
||||
{/* Right column streams independently */}
|
||||
<Suspense fallback={<RightColumnSkeleton />}>
|
||||
<RightColumn />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LeftColumn() {
|
||||
// These load together (same Suspense boundary)
|
||||
const { data: stats } = useSuspenseQuery(dashboardQueries.stats())
|
||||
const { data: chart } = useSuspenseQuery(dashboardQueries.chartData())
|
||||
|
||||
return (
|
||||
<div>
|
||||
<StatsCard stats={stats} />
|
||||
<ChartDisplay data={chart} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Progressive Enhancement
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Critical: post content (await)
|
||||
await queryClient.ensureQueryData(postQueries.detail(params.postId))
|
||||
|
||||
// Start but don't block: comments, related posts
|
||||
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
|
||||
queryClient.prefetchQuery(postQueries.related(params.postId))
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
const { postId } = Route.useParams()
|
||||
const { data: post } = useSuspenseQuery(postQueries.detail(postId))
|
||||
|
||||
return (
|
||||
<article>
|
||||
{/* Streams immediately */}
|
||||
<PostHeader post={post} />
|
||||
<PostContent content={post.content} />
|
||||
|
||||
{/* Streams when ready */}
|
||||
<Suspense fallback={<CommentsSkeleton />}>
|
||||
<CommentsSection postId={postId} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<RelatedSkeleton />}>
|
||||
<RelatedPosts postId={postId} />
|
||||
</Suspense>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Error Boundaries with Streaming
|
||||
|
||||
```tsx
|
||||
function DashboardPage() {
|
||||
return (
|
||||
<div>
|
||||
<Header />
|
||||
|
||||
{/* Each section handles its own errors */}
|
||||
<ErrorBoundary fallback={<StatsError />}>
|
||||
<Suspense fallback={<StatsSkeleton />}>
|
||||
<DashboardStats />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary fallback={<ActivityError />}>
|
||||
<Suspense fallback={<ActivitySkeleton />}>
|
||||
<RecentActivity />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming Timeline
|
||||
|
||||
```
|
||||
Traditional SSR:
|
||||
Request → [Wait for all data...] → Send complete HTML → Render
|
||||
|
||||
Streaming SSR:
|
||||
Request → Send shell HTML → Stream chunk 1 → Stream chunk 2 → Stream chunk 3 → Done
|
||||
↓ ↓ ↓ ↓
|
||||
Browser renders Shows content More content Complete
|
||||
skeleton progressively
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Suspense boundaries define streaming chunks
|
||||
- Place boundaries around slow or non-critical content
|
||||
- Critical path data should still be awaited in loader
|
||||
- Each Suspense boundary can error independently
|
||||
- Works with React 18's streaming SSR
|
||||
- Monitor TTFB to verify streaming is working
|
||||
- Consider network conditions - too many chunks can slow total load
|
||||
Reference in New Issue
Block a user