Extending the Application (Tutorial)
Relevant source files
The following files were used as context for generating this wiki page:
This tutorial provides a step-by-step guide on how to extend the application by adding a new feature. We will walk through the process of creating a new data entity, defining its API contract, implementing the backend logic, and building the corresponding user interface. The architecture is designed around a central API contract, ensuring type safety across the entire stack, from the frontend React application to the backend Hono API.
The project is a Turborepo monorepo with a clear separation of concerns. We will touch upon the key packages involved in adding a feature: packages/db for database schema, packages/contract for the API definition, apps/api for the server implementation, and apps/web for the frontend. Throughout this tutorial, we will use the existing planets feature, found in the API's fake database, as a reference model.
Sources: README.md, apps/api/src/middlewares/db.ts
Core Architecture Overview
The application follows a contract-based architecture. The packages/contract defines the "source of truth" for the API, which is then used by both the api and web applications to generate type-safe clients and servers.
This diagram illustrates the high-level data flow and package dependencies.
Sources: README.md
Step 1: Define the Data Model
The first step is to define the database schema for our new feature. This project uses Drizzle ORM.
1.1. Create a New Schema File
Schema definitions are located in packages/db/src/schema/. While the files for user, session, etc., are not fully shown, their usage in apps/api/src/lib/auth.ts confirms this structure. To add a new stars table, you would create a new file (e.g., packages/db/src/schema/stars.ts) and define the table using Drizzle's syntax.
// Example: packages/db/src/schema/stars.ts
import { serial, text, pgTable } from "drizzle-orm/pg-core";
import { user } from "./index"; // Assuming user schema is exported from index
export const star = pgTable("star", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
// Foreign key to the user table
creatorId: text("creator_id").references(() => user.id),
});
This is a hypothetical example based on Drizzle ORM conventions and existing schema references.
Sources: apps/api/src/lib/auth.ts:13-18
1.2. Push Schema Changes
After defining the new table, you must push the changes to the database. The packages/db contains a script for this purpose.
Run the following command from the root of the monorepo:
pnpm -F @contract-based-monorepo/db push
This command uses drizzle-kit push to synchronize the database schema with your Drizzle definitions.
Sources: README.md:80, packages/db/package.json:15
Step 2: Define the API Contract
With the data model in place, we now define the API contract in the packages/contract package. This package uses Zod for schema validation and ORPC to define RPC-style procedures.
2.1. Define Zod Schemas
First, create Zod schemas that represent the Star entity, including variations for creation and updates. These schemas will be used for input validation and to infer TypeScript types. The existing Planet types in apps/api/src/middlewares/db.ts serve as a good template.
// Example: packages/contract/src/schemas/star.ts
import { z } from "zod";
import { UserSchema } from "./user"; // Assuming a user schema exists
export const StarSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string().optional(),
imageUrl: z.string().optional(),
creator: UserSchema,
});
export const NewStarSchema = StarSchema.omit({ id: true, creator: true });
export const UpdateStarSchema = StarSchema.omit({ creator: true });
export type Star = z.infer<typeof StarSchema>;
export type NewStar = z.infer<typeof NewStarSchema>;
export type UpdateStar = z.infer<typeof UpdateStarSchema>;
This is a hypothetical example based on the Planet types.
Sources: apps/api/src/middlewares/db.ts:2-7, packages/contract/package.json:14-16
2.2. Define ORPC Procedures
Next, define the procedures for interacting with stars. This is done by creating a contract object using @orpc/contract.
// Example: packages/contract/src/contract/index.ts
import { contract } from "@orpc/contract";
import { z } from "zod";
import { StarSchema, NewStarSchema, UpdateStarSchema } from "../schemas/star";
export const starContract = contract()
.query("list", {
input: z.object({ limit: z.number(), cursor: z.number() }),
output: z.array(StarSchema),
})
.query("find", {
input: z.object({ id: z.number() }),
output: StarSchema.optional(),
})
.mutation("create", {
input: NewStarSchema,
output: StarSchema,
})
.mutation("update", {
input: UpdateStarSchema,
output: StarSchema,
});
// Add to the main app contract
export const appContract = contract({
stars: starContract,
// ... other contracts
});
This is a hypothetical example based on ORPC conventions.
Sources: packages/contract/package.json:14, apps/api/src/handlers/openapi.ts:2
Step 3: Implement the API Endpoint
Now we implement the server-side logic for the procedures defined in the contract. This happens in the apps/api package.
3.1. Implement Data Logic
The project uses a middleware to provide a database client to the request context. The file apps/api/src/middlewares/db.ts contains a createFakeDB function that implements the logic for planets. We can extend this to include logic for stars.
The following sequence diagram shows how a request is processed through the API.
Sources: apps/api/src/middlewares/db.ts:15-27
3.2. Create API Router
The API router connects the contract procedures to their implementations.
// Example: apps/api/src/router/stars.ts
import { starContract } from "@contract-based-monorepo/contract";
import { os } from "@orpc/server";
import { dbProviderMiddleware } from "../middlewares/db";
export const starsRouter = os.router(starContract, {
middlewares: [dbProviderMiddleware],
queries: {
list: async ({ input, context }) => {
return context.db.stars.list(input.limit, input.cursor);
},
find: async ({ input, context }) => {
return context.db.stars.find(input.id);
},
},
mutations: {
create: async ({ input, context }) => {
// Assuming user is available in context from an auth middleware
return context.db.stars.create(input, context.user);
},
update: async ({ input, context }) => {
return context.db.stars.update(input);
},
},
});
This is a hypothetical example based on the structure in apps/api/src/middlewares/db.ts.
3.3. Expose via OpenAPI
The API automatically generates an OpenAPI specification. By adding the new contract, the stars endpoints will be included in the documentation available at /api/docs.
Sources: apps/api/src/handlers/openapi.ts:10-29
Step 4: Create the Frontend UI
The final step is to build the user interface in the apps/web application to consume the new API endpoint.
4.1. Add a New Route
The apps/web app uses TanStack Router with file-based routing. To create a new page for stars, add a file like src/routes/stars.tsx.
The following flowchart shows the process of adding a new route.
4.2. Fetch and Display Data
Inside your new route component, use TanStack Query to fetch data from the API. The apps/web/README.md provides detailed examples of using useQuery.
// Example: apps/web/src/routes/stars.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
// Assume an ORPC client is set up
import { orpcClient } from "../lib/orpc";
export const Route = createFileRoute("/stars")({
component: StarsComponent,
});
function StarsComponent() {
const { data: stars, isLoading } = useQuery({
queryKey: ["stars"],
queryFn: () => orpcClient.stars.list.query({ limit: 10, cursor: 0 }),
});
if (isLoading) return <div>Loading...</div>;
return (
<div>
<h1>Stars</h1>
<ul>
{stars?.map((star) => (
<li key={star.id}>{star.name}</li>
))}
</ul>
</div>
);
}
This is a hypothetical example based on patterns from apps/web/README.md and apps/web/src/routes/index.tsx.
Summary
By following these steps, you can add new, type-safe features to the application. The process starts with the database schema, flows through the API contract and implementation, and ends with the frontend UI. This contract-first approach ensures that all parts of the system are synchronized, reducing runtime errors and improving developer productivity. The clear project structure and defined scripts for common tasks like database migrations and development server startup streamline the entire workflow.
Sources: README.md