Routing
Learn how to create and manage routes in ProductReady using Next.js App Router, tRPC, Hono OpenAPI, and Wouter for SPA routing.
Overview
ProductReady uses a hybrid routing architecture that combines Next.js App Router for server-side rendering, tRPC for type-safe APIs, Hono + OpenAPI for REST APIs, and Wouter for client-side SPA routing in dashboard areas.
This guide will help you understand when to use each routing approach and how to create new routes.
Main Application Routes
ProductReady comes with several pre-built routes ready to use:
Landing Page (/)
Route: src/app/[lang]/(home)/page.tsx
The marketing homepage featuring:
- Hero section with call-to-action
- Feature highlights
- Pricing information
- Responsive design with i18n support
Access: Public - no authentication required
Use case: Marketing, lead generation, product information
Dashboard (/dashboard)
Route: src/app/(dashboard)/dashboard/page.tsx
Main application interface with:
- Task management system
- Analytics and metrics
- Recent activity feed
- Quick actions and shortcuts
- Uses Wouter for client-side SPA routing
Access: Protected - requires authentication (or ?demo=1 for demo mode)
Use case: Main user workspace, task tracking, data visualization
Agent Tasks (/agent)
Route: src/app/(dashboard)/agent/[[...slug]]/page.tsx
AI agent task management interface featuring:
- Task creation and monitoring
- Real-time streaming chat with AI agents
- Background task execution with resume streams
- Artifact generation and display (two-column layout)
- Task status tracking and history
Access: Protected - requires authentication (or ?demo=1 for demo mode)
Use case: AI-powered automation, long-running tasks, agent workflows
Key Features:
- Resume Streams: Tasks continue running even if user disconnects
- Polymorphic Artifacts: Generated outputs can link to posts and other entities
- Background Execution: Tasks run server-side without user presence
System Admin (/systemadmin)
Route: src/app/(dashboard)/systemadmin/[[...slug]]/page.tsx
System administration panel with role-based access control:
- User management (create, edit, delete users)
- System admin management (owner/admin roles)
- System settings and configuration
- Access logs and monitoring
- Uses Wouter for client-side SPA routing
Access: Restricted - requires system admin privileges (owner or admin role)
Authorization:
- Page-level: Checks
system_adminstable on every page load - API-level: All operations protected with
systemAdminProcedure - Role hierarchy:
- Owner: Full control, can manage other admins
- Admin: Can manage users but not other admins
Use case: Platform administration, user provisioning, security management
Default Access: No users have admin access by default. Run pnpm db:seed to create owner account (admin@productready.dev). Password is randomly generated and shown in seed output, or set via SYSTEM_ADMIN_PASSWORD env var.
Routing Types
1. Next.js App Router (Server Pages)
Use for: SEO-critical pages, marketing pages, blog posts, documentation
Location: src/app/[lang]/(group)/page.tsx
Example:
// src/app/[lang]/(home)/pricing/page.tsx
export default function PricingPage() {
return (
<div>
<h1>Pricing</h1>
{/* SSR content with SEO metadata */}
</div>
);
}
// Optional: Add metadata for SEO
export const metadata = {
title: "Pricing - ProductReady",
description: "Choose the perfect plan for your needs",
};Creating a new page:
-
Create a file in the appropriate route group:
(home)- Public marketing pages(legal)- Terms, privacy policy(dashboard)- Dashboard root pages (with Wouter inside)
-
Export a default React component
-
Add metadata for SEO (optional)
2. tRPC API Routes (Type-Safe APIs)
Use for: Internal APIs, real-time data fetching, mutations, type-safe client-server communication
Location: src/server/routers/*.ts
Example:
Backend (Router):
// src/server/routers/posts.ts
import { z } from "zod";
import { protectedProcedure, publicProcedure, router } from "../trpc";
export const postsRouter = router({
// Query - fetch data
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
})
)
.query(async ({ ctx, input }) => {
const posts = await ctx.db.query.posts.findMany({
limit: input.limit,
offset: input.offset,
});
return { posts, total: posts.length };
}),
// Mutation - modify data
create: protectedProcedure
.input(
z.object({
title: z.string().min(1),
content: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const post = await ctx.db.insert(posts).values({
userId: ctx.user.id,
title: input.title,
content: input.content,
});
return post;
}),
});Register the router:
// src/server/routers/index.ts
import { router } from "../trpc";
import { postsRouter } from "./posts";
export const appRouter = router({
posts: postsRouter,
// ... other routers
});Frontend (Client):
"use client";
import { api } from "~/lib/trpc/client";
function PostsList() {
const { data, isLoading } = api.posts.list.useQuery({ limit: 10, offset: 0 });
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}Benefits:
- ✅ Full TypeScript end-to-end type safety
- ✅ No need to write API endpoints manually
- ✅ Built-in React Query integration
- ✅ Automatic request deduplication & caching
3. Hono OpenAPI Routes (REST APIs)
Use for: Public REST APIs, third-party integrations, mobile apps, webhooks
Location: src/app/api/v1/[[...server]]/app.ts
Example:
// src/app/api/v1/[[...server]]/app.ts
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
export const app = new OpenAPIHono().basePath("/api/v1");
// Define route with OpenAPI spec
const getUserRoute = createRoute({
method: "get",
path: "/users/{id}",
request: {
params: z.object({
id: z.string().openapi({ example: "123" }),
}),
},
responses: {
200: {
description: "User details",
content: {
"application/json": {
schema: z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
}),
},
},
},
404: {
description: "User not found",
content: {
"application/json": {
schema: z.object({
error: z.string(),
}),
},
},
},
},
});
// Implement the handler
app.openapi(getUserRoute, async (c) => {
const { id } = c.req.param();
const user = await db.query.users.findFirst({
where: eq(users.id, id),
});
if (!user) {
return c.json({ error: "User not found" }, 404);
}
return c.json({
id: user.id,
name: user.name,
email: user.email,
});
});
// Auto-generated OpenAPI docs at /api/v1/openapi.json
app.doc("/openapi.json", {
openapi: "3.0.0",
info: {
title: "ProductReady API",
version: "1.0.0",
},
});Access:
- API endpoint:
https://yourapp.com/api/v1/users/123 - OpenAPI spec:
https://yourapp.com/api/v1/openapi.json - Swagger UI:
https://yourapp.com/api/v1/doc
Benefits:
- ✅ Automatic OpenAPI documentation generation
- ✅ Interactive Swagger UI for testing
- ✅ Zod schema validation
- ✅ Perfect for external/public APIs
System Admin API:
A special subset of OpenAPI routes for site-level operations using a global secret key (SYSTEM_ADMIN_API_SECRET_KEY).
# System admin endpoint example
GET /api/v1/system-admin/users
Authorization: Bearer $SYSTEM_ADMIN_API_SECRET_KEYUse for: Microservices communication, cross-service user management, system-wide admin operations
Learn more: API Routing Decision Guide
4. Wouter SPA Routes (Client-Side Routing)
Use for: Dashboard internal navigation, wizard flows, tabs, multi-step forms
Location: src/app/(dashboard)/*/[[...slug]]/page.tsx
Example:
// src/app/(dashboard)/agent/[[...slug]]/page.tsx
"use client";
export const dynamic = "force-dynamic"; // Required for Wouter
import { Route, Router, Switch } from "wouter";
import { DashLink } from "~/components/DashLink";
export default function AgentPage() {
return (
<SPAWrapper>
<Router base="/agent">
<Switch>
{/* List view */}
<Route path="/">
<AgentList />
</Route>
{/* Detail view with ID */}
<Route path="/:id">
{(params) => <AgentDetail taskId={params.id} />}
</Route>
{/* Settings */}
<Route path="/settings">
<AgentSettings />
</Route>
{/* 404 fallback */}
<Route>
<NotFound />
</Route>
</Switch>
</Router>
</SPAWrapper>
);
}Navigation with DashLink:
import { DashLink } from "~/components/DashLink";
function TaskItem({ task }) {
return (
<DashLink href={`/${task.id}`}>
<div>{task.title}</div>
</DashLink>
);
}Benefits:
- ✅ Instant client-side navigation (no page reload)
- ✅ Preserves demo mode (
?demo=1) automatically - ✅ Better UX for dashboard interactions
- ✅ Smaller bundle size than Next.js router in client components
When to use DashLink:
- ✅ Navigation within SPA routes (
/agent/*,/dashboard/*,/kadmin/*) - ❌ Don't use for cross-section navigation (use Next.js
<Link>instead)
Route Examples
Example 1: Create a New Public Page
Goal: Add a new "Features" page at /features
// src/app/[lang]/(home)/features/page.tsx
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Features - ProductReady",
description: "Explore powerful features of ProductReady",
};
export default function FeaturesPage() {
return (
<div className="container mx-auto py-12">
<h1 className="text-4xl font-bold">Features</h1>
<p>Discover what makes ProductReady special...</p>
</div>
);
}Access: https://yourapp.com/en/features or https://yourapp.com/zh-CN/features
Example 2: Add a New tRPC Endpoint
Goal: Add an endpoint to fetch user statistics
// src/server/routers/analytics.ts
import { z } from "zod";
import { protectedProcedure, router } from "../trpc";
export const analyticsRouter = router({
getUserStats: protectedProcedure
.input(z.object({ userId: z.string() }))
.query(async ({ ctx, input }) => {
// Fetch stats from database
const stats = await ctx.db.query.userStats.findFirst({
where: eq(userStats.userId, input.userId),
});
return {
totalPosts: stats?.totalPosts ?? 0,
totalViews: stats?.totalViews ?? 0,
lastActive: stats?.lastActive ?? new Date(),
};
}),
});
// Register in src/server/routers/index.ts
export const appRouter = router({
analytics: analyticsRouter,
// ...
});Usage:
"use client";
const { data } = api.analytics.getUserStats.useQuery({ userId: user.id });Example 3: Add a Wouter SPA Route
Goal: Add a "History" tab in the agent dashboard
// src/app/(dashboard)/agent/[[...slug]]/page.tsx
<Router base="/agent">
<Switch>
<Route path="/history">
<AgentHistory />
</Route>
{/* ... other routes */}
</Switch>
</Router>
// Navigation
<DashLink href="/history">
<Button>View History</Button>
</DashLink>Decision Tree: Which Routing to Use?
┌─────────────────────────────────────────┐
│ Need SEO / Server-Side Rendering? │
└──────────────┬──────────────────────────┘
│
┌───────┴────────┐
│ YES │ NO
▼ ▼
┌─────────────┐ ┌─────────────────────┐
│ Next.js │ │ Internal/External? │
│ App Router │ └──────────┬──────────┘
└─────────────┘ │
- Marketing ┌────────┴─────────┐
- Blog │ Internal │ External
- Docs ▼ ▼
- Legal ┌──────────────┐ ┌─────────────┐
│ Type Safety? │ │ Hono OpenAPI│
└──────┬───────┘ └─────────────┘
│ - REST API
┌─────────┴────────┐ - Webhooks
│ YES │ NO│ Mobile apps
▼ ▼
┌─────────┐ ┌──────────┐
│ tRPC │ │ Wouter │
└─────────┘ └──────────┘
- Queries - Dashboard
- Mutations - SPA routing
- Subscriptions - Client navBest Practices
✅ Do's
- Use tRPC for all internal API calls (queries, mutations)
- Use Wouter for dashboard navigation to improve UX
- Use
DashLinkwithin SPA routes to preserve demo mode - Add metadata to all public Next.js pages for SEO
- Use route groups
(group)to organize pages logically - Validate inputs with Zod schemas in both tRPC and Hono routes
- Use
protectedProcedurefor authenticated endpoints in tRPC - Document OpenAPI routes with descriptions and examples
❌ Don'ts
- Don't use Wouter for cross-section navigation (use Next.js
<Link>) - Don't mix routing types in the same component (pick one)
- Don't forget to add
export const dynamic = "force-dynamic"for Wouter pages - Don't expose sensitive data in OpenAPI routes without authentication
- Don't skip input validation in API routes
- Don't hardcode URLs - use environment variables for external services
File Structure
src/
├── app/
│ ├── [lang]/ # Internationalized routes
│ │ ├── (home)/ # Public pages
│ │ │ ├── page.tsx # Homepage (/)
│ │ │ ├── pricing/ # Pricing page
│ │ │ └── features/ # Features page
│ │ ├── (legal)/ # Legal pages
│ │ │ ├── privacy-policy/
│ │ │ └── terms-of-service/
│ │ ├── docs/ # Documentation
│ │ └── blog/ # Blog posts
│ ├── (dashboard)/ # Dashboard with Wouter
│ │ ├── agent/ # Agent SPA routes
│ │ │ └── [[...slug]]/page.tsx
│ │ ├── dashboard/ # Dashboard SPA routes
│ │ └── kadmin/ # Admin SPA routes
│ └── api/ # API routes
│ ├── trpc/ # tRPC endpoint
│ ├── v1/ # Hono OpenAPI REST API
│ ├── auth/ # Better Auth
│ └── agent/ # Custom API routes
├── server/
│ └── routers/ # tRPC routers
│ ├── index.ts # Root router
│ ├── posts.ts
│ ├── users.ts
│ └── agent-tasks.ts
└── components/
└── DashLink.tsx # Wouter link with demo modeEnvironment Variables
Required for API routes:
# Database
DATABASE_URL="postgresql://..."
# Authentication (Better Auth)
BETTER_AUTH_SECRET="your-secret-key"
BETTER_AUTH_URL="http://localhost:3000"
# OpenAI (for AI features)
OPENAI_API_KEY="sk-..."
# Optional: External services for Hono APIs
STRIPE_SECRET_KEY="sk_test_..."Troubleshooting
Issue: Wouter routes not working
Solution: Make sure you added export const dynamic = "force-dynamic" at the top of your page component.
"use client";
export const dynamic = "force-dynamic"; // ← Add this
import { Router } from "wouter";
// ...Issue: tRPC type errors
Solution: Regenerate types after adding new routers:
pnpm typecheckMake sure your router is registered in src/server/routers/index.ts.
Issue: Demo mode not persisting in SPA
Solution: Use DashLink instead of regular <Link> or <a> tags:
// ❌ Wrong
<Link href="/agent/123">View Task</Link>
// ✅ Correct
<DashLink href="/123">View Task</DashLink>Issue: OpenAPI route returns 404
Solution: Check that you're accessing the correct path with /api/v1 prefix:
# ❌ Wrong
curl https://yourapp.com/users/123
# ✅ Correct
curl https://yourapp.com/api/v1/users/123Next Steps
- Authentication - Protect routes with Better Auth
- Database - Query data with Drizzle ORM
- Testing - Test tRPC routers and API routes
- Deployment - Deploy your routes to production