Frontend Development Guide
Last updated: 2025-06-12
Overview
The Tenki frontend is built with Next.js 15, React 19, and TypeScript. We use tRPC for type-safe API communication, Tailwind CSS for styling, and Radix UI for accessible components.
Tech Stack
- Framework: Next.js 15 (App Router)
- Language: TypeScript
- Styling: Tailwind CSS + Radix UI
- State: React Context + Zustand
- API: tRPC
- Forms: React Hook Form + Zod
- Testing: Jest + React Testing Library
Project Structure
apps/app/
├── src/
│ ├── app/ # Next.js app router pages
│ │ ├── (dashboard)/ # Protected routes
│ │ ├── auth/ # Auth pages
│ │ └── api/ # API routes
│ ├── components/ # Reusable components
│ ├── hooks/ # Custom hooks
│ ├── server/ # Server-side code
│ │ └── api/ # tRPC routers
│ ├── trpc/ # tRPC client setup
│ └── utils/ # Utilities
├── public/ # Static assets
└── next.config.mjs # Next.js config
Development Workflow
Running the Frontend
# Start all services (recommended)
pnpm dev
# Or just the frontend
pnpm -F app dev
# Access at
open https://app.tenki.lab:4001
Creating Components
// components/project-card.tsx
interface ProjectCardProps {
project: Project;
onSelect?: (project: Project) => void;
}
export function ProjectCard({ project, onSelect }: ProjectCardProps) {
return (
<Card onClick={() => onSelect?.(project)} className="cursor-pointer transition-shadow hover:shadow-lg">
<CardHeader>
<CardTitle>{project.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{project.description}</p>
</CardContent>
</Card>
);
}
Using tRPC
// In a client component
"use client";
import { trpc } from "@/trpc/client";
export function ProjectList() {
const { data: projects, isLoading } = trpc.project.list.useQuery();
const createProject = trpc.project.create.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.project.list.invalidate();
},
});
if (isLoading) return <Skeleton />;
return (
<div>
{projects?.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
);
}
Creating tRPC Routes
// server/api/routers/project.ts
export const projectRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.project.findMany({
where: { workspaceId: ctx.session.workspaceId },
});
}),
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
description: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
return ctx.db.project.create({
data: {
...input,
workspaceId: ctx.session.workspaceId,
},
});
}),
});
Styling Guidelines
Using Tailwind
// Use semantic color classes
<div className="bg-background text-foreground">
<button className="bg-primary text-primary-foreground hover:bg-primary/90">
Click me
</button>
</div>
// Responsive design
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Grid items */}
</div>
// Dark mode support (automatic)
<div className="bg-white dark:bg-gray-900">
Content adapts to theme
</div>
Component Composition
// Use Radix UI primitives
import * as Dialog from "@radix-ui/react-dialog";
export function CreateProjectDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button>Create Project</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50" />
<Dialog.Content className="bg-background fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-lg p-6">
<Dialog.Title>Create Project</Dialog.Title>
{/* Form content */}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
State Management
Local State
// For simple component state
const [isOpen, setIsOpen] = useState(false);
Context for Feature State
// contexts/project-context.tsx
const ProjectContext = createContext<ProjectContextType | null>(null);
export function ProjectProvider({ children }: { children: ReactNode }) {
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
return <ProjectContext.Provider value={{ selectedProject, setSelectedProject }}>{children}</ProjectContext.Provider>;
}
export function useProject() {
const context = useContext(ProjectContext);
if (!context) throw new Error("useProject must be used within ProjectProvider");
return context;
}
Global State with Zustand
// stores/user-preferences.ts
import { create } from "zustand";
interface PreferencesStore {
theme: "light" | "dark" | "system";
setTheme: (theme: PreferencesStore["theme"]) => void;
}
export const usePreferences = create<PreferencesStore>((set) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
}));
Forms
With React Hook Form + Zod
const ProjectSchema = z.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
isPublic: z.boolean().default(false),
});
type ProjectForm = z.infer<typeof ProjectSchema>;
export function CreateProjectForm() {
const form = useForm<ProjectForm>({
resolver: zodResolver(ProjectSchema),
defaultValues: {
name: "",
isPublic: false,
},
});
const onSubmit = async (data: ProjectForm) => {
await createProject.mutateAsync(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Create</Button>
</form>
</Form>
);
}
Testing
Component Tests
// __tests__/project-card.test.tsx
import { ProjectCard } from "@/components/project-card";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("ProjectCard", () => {
it("displays project information", () => {
const project = { id: "1", name: "Test Project", description: "Test" };
render(<ProjectCard project={project} />);
expect(screen.getByText("Test Project")).toBeInTheDocument();
expect(screen.getByText("Test")).toBeInTheDocument();
});
it("calls onSelect when clicked", async () => {
const onSelect = jest.fn();
const project = { id: "1", name: "Test Project" };
render(<ProjectCard project={project} onSelect={onSelect} />);
await userEvent.click(screen.getByRole("article"));
expect(onSelect).toHaveBeenCalledWith(project);
});
});
Running Tests
# Run all tests
pnpm test
# Watch mode
pnpm test:watch
# With coverage
pnpm test:coverage
Performance
Image Optimization
import Image from "next/image";
<Image
src="/logo.png"
alt="Logo"
width={200}
height={50}
priority // For above-the-fold images
/>;
Code Splitting
// Dynamic imports for heavy components
const HeavyChart = dynamic(() => import("@/components/heavy-chart"), {
loading: () => <Skeleton className="h-96" />,
ssr: false, // Disable SSR for client-only components
});
Data Fetching
// Server component (default in app router)
async function ProjectPage({ params }: { params: { id: string } }) {
const project = await api.project.get({ id: params.id });
return <ProjectDetails project={project} />;
}
// Parallel data fetching
async function DashboardPage() {
const [projects, stats] = await Promise.all([api.project.list(), api.stats.get()]);
return (
<>
<StatsCard stats={stats} />
<ProjectList projects={projects} />
</>
);
}
Common Patterns
Error Boundaries
export function ProjectErrorBoundary({ children }: { children: ReactNode }) {
return (
<ErrorBoundary
fallback={
<Alert variant="destructive">
<AlertTitle>Something went wrong</AlertTitle>
<AlertDescription>Unable to load projects. Please try again.</AlertDescription>
</Alert>
}
>
{children}
</ErrorBoundary>
);
}
Loading States
export function ProjectListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
);
}
Accessibility
// Always include ARIA labels
<button
aria-label="Delete project"
onClick={handleDelete}
>
<TrashIcon />
</button>
// Keyboard navigation
<div
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleClick();
}
}}
>
Interactive element
</div>
Debugging
React DevTools
- Install React Developer Tools extension
- Use Components tab to inspect props/state
- Use Profiler tab for performance analysis
tRPC DevTools
// Automatically included in development
// View network tab for tRPC requests
// Check request/response payloads
Common Issues
Hydration Errors
// Ensure client/server render match
{
typeof window !== "undefined" && <ClientOnlyComponent />;
}
State Not Updating
// Use callbacks for state depending on previous
setItems((prev) => [...prev, newItem]);