This template gives you a React 19 + Tailwind 4 + Express 4 + tRPC 11 stack with Manus OAuth already wired. Procedures are your contracts, types flow end to end, and authentication “just works”.
server/routers.ts, consume them with trpc.* hooks.Date stays a Date./api/oauth/callback handles Manus OAuth, protectedProcedure injects ctx.user./api/trpc, making it easy to route at the edge.drizzle/schema.ts, then run pnpm db:push.server/db.ts (return raw results).server/routers.ts, then wire the UI with trpc.*.useQuery/useMutation.Frontend Workflowserver/*.test.ts (see server/auth.logout.test.ts) and run pnpm test.That’s it—no manual REST routes, no Axios client, no shared contract files.
server/auth.logout.test.ts → Reference sample vitest test file
drizzle/schema.ts → Database tables & types
server/db.ts → Query helpers (reuse across procedures)
server/routers.ts → tRPC procedures (auth + features)
client/src/App.tsx → Routes wiring & layout shells
client/src/lib/trpc.ts → tRPC client binding
client/src/pages/ → Feature UI that calls trpc hooks
Framework plumbing (OAuth, context, Vite bridge) lives under server/_core.
client/
public/ ← Small configuration files ONLY (favicon.ico, robots.txt). DO NOT put images/media here.
src/
pages/ ← Page-level components
components/ ← Reusable UI & shadcn/ui
contexts/ ← React contexts
hooks/ ← Custom hooks
lib/trpc.ts ← tRPC client
App.tsx ← Routes & layout
main.tsx ← Providers
index.css ← global style
drizzle/ ← Schema & migrations
server/
db.ts ← Query helpers
routers.ts ← tRPC procedures
storage/ ← S3 helpers
shared/ ← Shared constants & types
Only touch the files under “←” markers. Anything under server/_core or other tooling directories is framework-level—avoid editing unless you are extending the infrastructure.
DO NOT store images, videos, or large assets in client/public/ or client/src/assets/. Local media files will cause deployment timeouts.
Required workflow:
manus-upload-file --webdev path/to/image.png<img src="/manus-storage/image_a1b2c3d4.png" />/home/ubuntu/webdev-static-assets/ (outside the project directory)Only small configuration files like favicon.ico, robots.txt, and manifest.json belong in client/public/.
Files in client/public are available at the root of your site—reference them with absolute paths (/robots.txt, etc.) from HTML templates, JSX, or meta tags.
/api/oauth/callback and drops a session cookie./api/trpc builds context via server/_core/context.ts, making the current user available as ctx.user.protectedProcedure; public access uses publicProcedure.trpc.auth.me.useQuery() and invokes trpc.auth.logout.useMutation()—no cookie plumbing required.Available pre-defined system envs:
DATABASE_URL: MySQL/TiDB connection stringJWT_SECRET: Session cookie signing secretVITE_APP_ID: Manus OAuth application IDOAUTH_SERVER_URL: Manus OAuth backend base URLVITE_OAUTH_PORTAL_URL: Manus login portal URL (frontend)OWNER_OPEN_ID, OWNER_NAME: Owner’s infoBUILT_IN_FORGE_API_URL: Manus built-in apis (includes llm, storage, data_api, notification, etc…)BUILT_IN_FORGE_API_KEY: Bearer token used by Manus built-in apis (server-side)VITE_FRONTEND_FORGE_API_KEY: Bearer token for frontend access to Manus built-in apisVITE_FRONTEND_FORGE_API_URL: Manus built-in apis URL for frontendDo not edit these directly in code or commit .env files.
The envs above are system envs, when use env in website code, refer server/_core/env.ts for available list.
client/src/index.css for global theming and add needed font using google font cdn in client/index.html.client/src/pages/Home.tsx (the landing page shell) using shadcn/ui components to introduce links, CTAs, or feature entry points.client/src/pages/FeatureName.tsx, continuing to leverage shadcn/ui + Tailwind for consistent styling.client/src/App.tsx.const { data, isLoading } = trpc.feature.useQuery(params);.trpc.feature.useMutation(). Use optimistic updates for list operations, toggles, and profile edits. For critical operations (payments, auth), use invalidate with loading states.useAuth() for current user state, login URL from getLoginUrl(), and avoid direct cookie handling.tRPC & Data Management:
trpc.*.useQuery/useMutation for all backend calls—never introduce Axios/fetch wrappers.onMutate to update cache, onError to rollback (The onMutate/onError/onSettled pattern). For critical operations (payments, auth), prefer invalidate with explicit loading states.invalidate as fallback: call trpc.useUtils().feature.invalidate() in mutation’s onSuccess.useAuth(); do not manipulate cookies manually.UI & Styling:
@/components/ui/* (e.g., button, card, dialog).variant, size, etc. where available.@layer base rules in client/src/index.css. Utilities like border-border and font-sans depend on them.components/ for reuse instead of copy‑paste.client/src/index.css instead of hard‑coding to keep global consistency.React Best Practices:
useEffectCustomized Defaults: This template customizes some Tailwind/shadcn defaults for simplified usage:
.container is customized to auto-center and add responsive padding (see index.css). Use directly without mx-auto/px-*. For custom widths, use max-w-* with mx-auto px-4..flex is customized to have min-width:0 and min-height:0 by defaultbutton variant outline uses transparent background (not bg-background). Add bg color class manually if needed.When generating frontend UI, avoid generic patterns that lack visual distinction:
Bake motion taste in from the first line of code. Snappy, physically intuitive interactions are not a polish pass — they are part of the initial build.
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);. For moving/morphing use --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);. NEVER use ease-in for UI animations — it feels sluggish.transform: scale(0.97) on :active with a ~160ms ease-out transition so the UI confirms it heard the user.scale(0) — nothing in the real world appears from nothing. Start from scale(0.95) combined with opacity: 0.transform-origin: var(--radix-popover-content-transform-origin)). Modals are the exception and stay centered.transform and opacity for motion — they run on the GPU and skip layout/paint. Avoid animating width, height, padding, margin, top/left unless absolutely necessary.prefers-reduced-motion: gate non-essential motion behind @media (prefers-reduced-motion: no-preference).drizzle/schema.ts, migrations pushed (pnpm db:push)server/db.ts (returns raw Drizzle rows)server/routers.ts (choose public vs protected)trpc.*.useQuery/useMutationBefore implementing UI features, check if these components already exist:
Dashboard & Layout:
client/src/components/DashboardLayout.tsx - Full dashboard layout with sidebar navigation, auth handling, and user profile. Use this for any admin panel or dashboard-style app instead of building from scratch.client/src/components/DashboardLayoutSkeleton.tsx - Loading skeleton for dashboard during auth checksChat & Messaging:
client/src/components/AIChatBox.tsx - Full-featured chat interface with message history, streaming support, and markdown rendering. Use this for any chat/conversation UI instead of building from scratch.Maps:
client/src/components/Map.tsx - Google Maps integration with proxy authentication. Provides MapView component with onMapReady callback for initializing Google Maps services (Places, Geocoder, Directions, Drawing, etc.). All map functionality works directly in the browser.When implementing features that match these categories, MUST evaluate the component first to decide whether to use or customize it.
For certain app types, this template provides DashboardLayout—a standardized sidebar pattern.
Use DashboardLayout for:
Do NOT use for:
Layout & Navigation
DashboardLayout component from client/src/components/DashboardLayout.tsx and remove any page-level headers to avoid duplication.Role-based Access Control When building apps with distinct access levels (e.g., e-commerce with public home, user account, admin panel):
The user table includes a role field (enum: admin |
user) for identity separation |
ctx.user.role in procedures to gate admin-only operationsadminProcedureuseAuth().user?.roleExample procedure pattern:
adminOnlyProcedure: protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') throw new TRPCError({ code: 'FORBIDDEN' });
return next({ ctx });
}),
Managing Admins
role field directly in the database via the system UI or SQLadmin/user, extend the enum in drizzle/schema.ts and push the migrationUse the preconfigured LLM helpers. Credentials are injected from the platform (no manual setup required).
import { invokeLLM } from "./server/_core/llm";
/**
* Simple chat completion
* type Role = "system" | "user" | "assistant" | "tool" | "function";
* type TextContent = {
* type: "text";
* text: string;
* };
*
* type ImageContent = {
* type: "image_url";
* image_url: {
* url: string;
* detail?: "auto" | "low" | "high";
* };
* };
*
* type FileContent = {
* type: "file_url";
* file_url: {
* url: string;
* mime_type?: "audio/mpeg" | "audio/wav" | "application/pdf" | "audio/mp4" | "video/mp4" ;
* };
* };
*
* export type Message = {
* role: Role;
* content: string | Array<ImageContent | TextContent | FileContent>
* };
*
* Supported parameters:
* messages: Array<{
* role: 'system' | 'user' | 'assistant' | 'tool',
* content: string | { tool_call: { name: string, arguments: string } }
* }>
* tool_choice?: 'none' | 'auto' | 'required' | { type: 'function', function: { name: string } }
* tools?: Tool[]
*/
const response = await invokeLLM({
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, world!" },
],
});
Tips
<Streamdown>{content}</Streamdown> (imported from streamdown) to render markdown content with proper formatting and streaming support.Ask the model to return structured JSON via response_format:
import { invokeLLM } from "./server/_core/llm";
const structured = await invokeLLM({
messages: [
{ role: "system", content: "You are a helpful assistant designed to output JSON." },
{ role: "user", content: "Extract the name and age from the following text: \"My name is Alice and I am 30 years old.\"" },
],
response_format: {
type: "json_schema",
json_schema: {
name: "person_info",
strict: true,
schema: {
type: "object",
properties: {
name: { type: "string", description: "The name of the person" },
age: { type: "integer", description: "The age of the person" },
},
required: ["name", "age"],
additionalProperties: false,
},
},
},
});
// The model responds with JSON content matching the schema.
// Access via `structured.choices[0].message.content` and JSON.parse if needed.
The helpers mirror the Python SDK semantics but produce JavaScript-first code, keeping credentials inside the server and ensuring every environment has access to the same token.
Use the preconfigured voice transcription helper that converts speech to text using Whisper API, no manual setup required.
Example usage:
import { transcribeAudio } from "./server/_core/voiceTranscription";
const result = await transcribeAudio({
audioUrl: "https://storage.example.com/audio/recording.mp3",
language: "en", // Optional: helps improve accuracy
prompt: "Transcribe meeting notes" // Optional: context hint
});
// Returns native Whisper API response
// result.text - Full transcription
// result.language - Detected language (ISO-639-1)
// result.segments - Timestamped segments with metadata
Tips
Use the preconfigured image generation helper that connects to the internal ImageService, no manual setup required.
Example usage:
import { generateImage } from "./server/_core/imageGeneration.ts";
const { url: imageUrl } = await generateImage({
prompt: "A serene landscape with mountains"
});
// For editing:
const { url: imageUrl } = await generateImage({
prompt: "Add a rainbow to this landscape",
originalImages: [{
url: "https://example.com/original.jpg",
mimeType: "image/jpeg"
}]
});
Tips
Use the preconfigured storage helpers in server/storage.ts. Credentials are injected from the platform (no manual setup required). Files are stored securely and served via the built-in /manus-storage/ path — no manual URL management needed.
import { storagePut } from "./server/storage";
// Upload bytes to storage
const fileKey = `${userId}-files/${fileName}.png`
const { key, url } = await storagePut(
fileKey,
fileBuffer, // Buffer | Uint8Array | string
"image/png"
);
// url = "/manus-storage/{key}" — use directly in frontend code
// key = unique storage key — save in database
Tips
key or url in your database; use storage for the actual file bytes. This applies to all files including images, documents, and media.storagePut from your backend.url (e.g. /manus-storage/...) is automatically served via signed redirect — no manual URL signing needed.key from your DB and any UI references — the key is the only way to reach the object, so an unreferenced file is effectively gone. Do not implement a helper to remove the underlying object; the template’s storage layer does not expose a delete endpoint.CRITICAL: The Manus proxy provides FULL access to ALL Google Maps features - including advanced drawing, heatmaps, Street View, all layers, Places API, etc. Do ask users for Google Map API keys - authentication is automatic.
Default: Use Frontend SDK - Import MapView from client/src/components/Map.tsx and initialize ANY Google Maps service (geocoding, directions, places, drawing, visualization, geometry, etc.) in the onMapReady callback.
Use Backend API only when:
Implementation:
client/src/components/Map.tsx for component usage - ALL Google Maps JavaScript API features workmakeRequest from server/_core/map.tsNEVER use external map libraries or request API keys from users - the Manus proxy handles everything automatically with no feature limitations.
When you need external data, use the omni_search with search_type = ‘api’ to see there’s any built-in api available in Manus API Hub access. You only have to connect other api if there’s no suitable built-in api available.
This template already ships with a notifyOwner({ title, content }) helper (server/_core/notification.ts) and a protected tRPC mutation at trpc.system.notifyOwner. Use it whenever backend logic needs to push an operational update to the Manus project owner—common triggers are new form submissions, survey feedback, or workflow results.
await notifyOwner({ title, content }) or reuse the provided system.notifyOwner mutation from jobs/webhooks (trpc.system.notifyOwner.useMutation() on the client).true on success, false if the upstream service is temporarily unavailable) to decide whether you need a fallback channel.Keep this channel for owner-facing alerts; end-user messaging should flow through your app-specific systems.
Persistence: Store all business timestamps as UTC-based Unix timestamps (milliseconds since epoch) at the database and API layer. Do not store client-local, timezone-dependent, or string-based timestamps unless explicitly required as separate fields. Frontend display: In React components, always convert UTC timestamps to the user’s local timezone for display e.g. new Date(utcTimestamp).toLocaleString(). Keep all internal state and API interactions in UTC timestamps to avoid drift and confusion.
server/routers/<feature>.ts once they grow.Note: All TODO comments are remarks for the agent (you), not for the user.
package.json
{
"name": "ismail-care-group",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "NODE_ENV=development tsx watch server/_core/index.ts",
"build": "vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"check": "tsc --noEmit",
"format": "prettier --write .",
"test": "vitest run",
"db:push": "drizzle-kit generate && drizzle-kit migrate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.693.0",
"@aws-sdk/s3-request-presigner": "^3.693.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"axios": "^1.12.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"cookie": "^1.0.2",
"date-fns": "^4.1.0",
"dotenv": "^17.2.2",
"drizzle-orm": "^0.44.5",
"embla-carousel-react": "^8.6.0",
"express": "^4.21.2",
"framer-motion": "^12.23.22",
"input-otp": "^1.4.2",
"jose": "6.1.0",
"lucide-react": "^0.453.0",
"mysql2": "^3.15.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"react": "^19.2.1",
"react-day-picker": "^9.11.1",
"react-dom": "^19.2.1",
"react-hook-form": "^7.64.0",
"react-resizable-panels": "^3.0.6",
"recharts": "^2.15.2",
"sonner": "^2.0.7",
"streamdown": "^1.4.0",
"superjson": "^1.13.3",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"wouter": "^3.3.5",
"zod": "^4.1.12"
},
"devDependencies": {
"@builder.io/vite-plugin-jsx-loc": "^0.1.1",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.1.3",
"@types/express": "4.17.21",
"@types/google.maps": "^3.58.1",
"@types/node": "^24.7.0",
"@types/react": "^19.2.1",
"@types/react-dom": "^19.2.1",
"@vitejs/plugin-react": "^5.0.4",
"add": "^2.0.6",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.31.4",
"esbuild": "^0.25.0",
"pnpm": "^10.15.1",
"postcss": "^8.4.47",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.14",
"tsx": "^4.19.1",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3",
"vite": "^7.1.7",
"vite-plugin-manus-runtime": "^0.0.57",
"vitest": "^2.1.4"
},
"packageManager": "pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af",
"pnpm": {
"patchedDependencies": {
"wouter@3.7.1": "patches/wouter@3.7.1.patch"
},
"overrides": {
"tailwindcss>nanoid": "3.3.7"
}
}
}
drizzle/schema.ts
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
/**
* Core user table backing auth flow.
* Extend this file with additional tables as your product grows.
* Columns use camelCase to match both database fields and generated types.
*/
export const users = mysqlTable("users", {
/**
* Surrogate primary key. Auto-incremented numeric value managed by the database.
* Use this for relations between tables.
*/
id: int("id").autoincrement().primaryKey(),
/** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */
openId: varchar("openId", { length: 64 }).notNull().unique(),
name: text("name"),
email: varchar("email", { length: 320 }),
loginMethod: varchar("loginMethod", { length: 64 }),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
});
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
// TODO: Add your tables here
server/db.ts
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import { InsertUser, users } from "../drizzle/schema";
import { ENV } from './_core/env';
let _db: ReturnType<typeof drizzle> | null = null;
// Lazily create the drizzle instance so local tooling can run without a DB.
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
_db = drizzle(process.env.DATABASE_URL);
} catch (error) {
console.warn("[Database] Failed to connect:", error);
_db = null;
}
}
return _db;
}
export async function upsertUser(user: InsertUser): Promise<void> {
if (!user.openId) {
throw new Error("User openId is required for upsert");
}
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot upsert user: database not available");
return;
}
try {
const values: InsertUser = {
openId: user.openId,
};
const updateSet: Record<string, unknown> = {};
const textFields = ["name", "email", "loginMethod"] as const;
type TextField = (typeof textFields)[number];
const assignNullable = (field: TextField) => {
const value = user[field];
if (value === undefined) return;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
};
textFields.forEach(assignNullable);
if (user.lastSignedIn !== undefined) {
values.lastSignedIn = user.lastSignedIn;
updateSet.lastSignedIn = user.lastSignedIn;
}
if (user.role !== undefined) {
values.role = user.role;
updateSet.role = user.role;
} else if (user.openId === ENV.ownerOpenId) {
values.role = 'admin';
updateSet.role = 'admin';
}
if (!values.lastSignedIn) {
values.lastSignedIn = new Date();
}
if (Object.keys(updateSet).length === 0) {
updateSet.lastSignedIn = new Date();
}
await db.insert(users).values(values).onDuplicateKeyUpdate({
set: updateSet,
});
} catch (error) {
console.error("[Database] Failed to upsert user:", error);
throw error;
}
}
export async function getUserByOpenId(openId: string) {
const db = await getDb();
if (!db) {
console.warn("[Database] Cannot get user: database not available");
return undefined;
}
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
return result.length > 0 ? result[0] : undefined;
}
// TODO: add feature queries here as your schema grows.
server/routers.ts
import { COOKIE_NAME } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, router } from "./_core/trpc";
export const appRouter = router({
// if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return {
success: true,
} as const;
}),
}),
// TODO: add feature routers here, e.g.
// todo: router({
// list: protectedProcedure.query(({ ctx }) =>
// db.getUserTodos(ctx.user.id)
// ),
// }),
});
export type AppRouter = typeof appRouter;
client/src/App.tsx
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
function Router() {
// make sure to consider if you need authentication for certain routes
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />
</Switch>
);
}
// NOTE: About Theme
// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css
// to keep consistent foreground/background color across components
// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook
function App() {
return (
<ErrorBoundary>
<ThemeProvider
defaultTheme="light"
// switchable
>
<TooltipProvider>
<Toaster />
<Router />
</TooltipProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
export default App;
client/src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../../server/routers";
export const trpc = createTRPCReact<AppRouter>();
client/src/pages/Home.tsx
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { getLoginUrl } from "@/const";
import { Streamdown } from 'streamdown';
/**
* All content in this page are only for example, replace with your own feature implementation
* When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls
*/
export default function Home() {
// The userAuth hooks provides authentication state
// To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()
let { user, loading, error, isAuthenticated, logout } = useAuth();
// If theme is switchable in App.tsx, we can implement theme toggling like this:
// const { theme, toggleTheme } = useTheme();
return (
<div className="min-h-screen flex flex-col">
<main>
{/* Example: lucide-react for icons */}
<Loader2 className="animate-spin" />
Example Page
{/* Example: Streamdown for markdown rendering */}
<Streamdown>Any **markdown** content</Streamdown>
<Button variant="default">Example Button</Button>
</main>
</div>
);
}
server/auth.logout.test.ts
import { describe, expect, it } from "vitest";
import { appRouter } from "./routers";
import { COOKIE_NAME } from "../shared/const";
import type { TrpcContext } from "./_core/context";
type CookieCall = {
name: string;
options: Record<string, unknown>;
};
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
function createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] } {
const clearedCookies: CookieCall[] = [];
const user: AuthenticatedUser = {
id: 1,
openId: "sample-user",
email: "sample@example.com",
name: "Sample User",
loginMethod: "manus",
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
lastSignedIn: new Date(),
};
const ctx: TrpcContext = {
user,
req: {
protocol: "https",
headers: {},
} as TrpcContext["req"],
res: {
clearCookie: (name: string, options: Record<string, unknown>) => {
clearedCookies.push({ name, options });
},
} as TrpcContext["res"],
};
return { ctx, clearedCookies };
}
describe("auth.logout", () => {
it("clears the session cookie and reports success", async () => {
const { ctx, clearedCookies } = createAuthContext();
const caller = appRouter.createCaller(ctx);
const result = await caller.auth.logout();
expect(result).toEqual({ success: true });
expect(clearedCookies).toHaveLength(1);
expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);
expect(clearedCookies[0]?.options).toMatchObject({
maxAge: -1,
secure: true,
sameSite: "none",
httpOnly: true,
path: "/",
});
});
});
Anti-pattern: Creating new objects/arrays in render that are used as query inputs
// ❌ Bad: New Date() creates new reference every render → infinite queries
const { data } = trpc.items.getByDate.useQuery({
date: new Date(), // ← New object every render!
});
// ❌ Bad: Array/object literals in query input
const { data } = trpc.items.getByIds.useQuery({
ids: [1, 2, 3], // ← New array reference every render!
});
Correct approach: Stabilize references with useState/useMemo
// ✅ Good: Initialize once with useState
const [date] = useState(() => new Date());
const { data } = trpc.items.getByDate.useQuery({ date });
// ✅ Good: Memoize complex inputs
const ids = useMemo(() => [1, 2, 3], []);
const { data } = trpc.items.getByIds.useQuery({ ids });
Why this happens: TRPC queries trigger when input references change. Objects/arrays created in render have new references each time, causing infinite re-fetches.
Anti-pattern: Adding BLOB/BYTEA columns to store file content
// ❌ Bad: Database bloat and slow queries
export const files = sqliteTable('files', {
content: blob('content'), // Never store file bytes
});
Correct approach: Store S3 reference only, upload file bytes to S3
// ✅ Good: Store metadata + S3 reference
export const files = sqliteTable('files', {
url: text('url').notNull(), // Url to reference the file in s3
fileKey: text('file_key').notNull(), // also save file_key for clarity
// optional, save other metadata if needed
// filename: text('filename'),
// mimeType: text('mime_type'),
});
Use storagePut() to upload files (see S3 File Storage section).
Problem: Creating nested routes without escape routes—no header nav, no sidebar, no back button.
Root cause: Implementing individual pages before establishing global layout structure.
Solution: Define layout wrapper in App.tsx first, then build pages inside it. For admin tools use DashboardLayout; for detail pages add back button with router.back().
Root cause: Semantic colors (bg-background, text-foreground) are CSS variables that resolve based on ThemeProvider’s active theme. Mismatches cause invisible text.
Two critical rules:
defaultTheme="dark" in App.tsx, ensure .dark {} in index.css has dark background + light foreground valuesbg-{semantic}, MUST also use text-{semantic}-foreground (not automatic - text inherits from parent otherwise)Quick reference:
// ✅ Theme + CSS alignment
<ThemeProvider defaultTheme="dark"> {/* Must match .dark in index.css */}
<div className="bg-background text-foreground">...</div>
</ThemeProvider>
// ✅ Required class pairs
<div className="bg-popover text-popover-foreground">...</div>
<div className="bg-card text-card-foreground">...</div>
<div className="bg-accent text-accent-foreground">...</div>
Problem: Wrapping <a> tags inside another <a> or wouter’s <Link> creates nested anchors and runtime errors.
Solution: Pass children directly to Link—it already renders an <a> internally.
// ❌ Bad: <Link><a>...</a></Link> or <a><a>...</a></a>
// ✅ Good: <Link>...</Link> or just <a>...</a>
Select.Item valuesRule: Every <Select.Item> must have a non-empty value prop—never "", undefined, or omitted.
Key Rule: Always use window.location.origin for redirect URLs—never hardcode domains or use req.host. Frontend and backend run on separate servers, so the frontend must pass its origin explicitly.
Unsupported browsers: Safari Private Browsing, Firefox Strict ETP, Brave Aggressive Shields, or any browser blocking cookies.
Anti-patterns:
// ❌ Never construct URLs from env vars or patterns
const url = `https://${projectName}.manus.space/callback`;
const url = `https://${process.env.APP_SUBDOMAIN}.example.com/verify`;
Correct approach: This template already implements the pattern correctly:
client/src/const.ts: getLoginUrl(returnPath?) encodes origin + returnPath in stateserver/_core/oauth.ts: parseState() extracts origin from state for redirectsFor invite/magic links: When backend generates URLs, frontend must pass origin in the request:
// Frontend
const createInvite = trpc.invites.create.useMutation();
await createInvite.mutateAsync({ eventId: "123", origin: window.location.origin });
// Backend - use input.origin to build the URL
const inviteUrl = `${input.origin}/events/${eventId}/join?token=${token}`;