Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Install React Developer Tools extension
  2. Use Components tab to inspect props/state
  3. 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]);

Resources