How to Add a New Feature to the Kaneo Monorepo: A Complete Development Guide
Adding a feature to Kaneo requires updating the database schema in apps/api/src/database/schema.ts, creating a controller in apps/api/src/<feature>/controllers/, exposing a Hono route in apps/api/src/<feature>/index.ts, and consuming it via a TanStack Query hook in apps/web/src/hooks/queries/<feature>/.
Kaneo is organized as a pnpm monorepo with separate apps/api (backend) and apps/web (frontend) packages. When you add a new feature to the kaneo monorepo, you work across the entire stack—from database schema definitions to React UI components—following strict architectural conventions documented in CLAUDE.md and CONTRIBUTING.md.
Planning the Feature
Before writing code, decide what data your feature needs and whether it requires database persistence. Determine which UI components will consume the new functionality and whether you need queries (read) or mutations (write). Kaneo uses Drizzle ORM for database operations, Hono for API routes, Valibot for validation, and TanStack Query for server state management on the frontend.
Backend Implementation
Database Schema Updates
If your feature requires data persistence, start by defining the table in apps/api/src/database/schema.ts. Kaneo uses Drizzle ORM with PostgreSQL.
// apps/api/src/database/schema.ts
export const tagTable = pgTable('tag', {
id: text('id')
.$defaultFn(() => createId())
.primaryKey(),
name: text('name').notNull(),
createdAt: timestamp('created_at', { mode: 'date' })
.defaultNow()
.notNull(),
});
After modifying the schema, generate a migration using the package filter:
pnpm --filter @kaneo/api db:generate
Creating Controllers
Business logic lives in feature-specific controller files under apps/api/src/<feature>/controllers/. These functions handle database interactions and return clean data structures.
// apps/api/src/tag/controllers/create-tag.ts
import { db } from '@/db';
import { tagTable } from '@/database/schema';
export async function createTag(name: string) {
const id = await db
.insert(tagTable)
.values({ name })
.returning({ id: tagTable.id });
return { id: id[0].id, name };
}
Defining API Routes with Hono
Routes are defined per-feature in apps/api/src/<feature>/index.ts using Hono and describeRoute from hono-openapi for automatic OpenAPI documentation.
// apps/api/src/tag/index.ts
import { Hono } from 'hono';
import { describeRoute, validator } from 'hono-openapi';
import * as v from 'valibot';
import { createTag } from './controllers/create-tag';
export const tag = new Hono()
.post(
'/',
describeRoute({
operationId: 'createTag',
tags: ['Tag'],
description: 'Create a new tag',
}),
validator('json', v.object({ name: v.string() })),
async (c) => {
const { name } = c.req.valid('json');
const tag = await createTag(name);
return c.json(tag, 201);
}
);
Input Validation with Valibot
Kaneo uses Valibot schemas to validate incoming request data. The validator middleware from hono-openapi applies these schemas before your route handler executes, ensuring type safety at runtime.
Frontend Implementation
API Fetchers
Frontend code communicates with the backend via small fetcher functions located in apps/web/src/fetchers/<feature>/. These thin wrappers handle the actual HTTP requests and error handling.
// apps/web/src/fetchers/tag/create-tag.ts
import { getApiUrl } from '@/fetchers/get-api-url';
export async function createTag(name: string) {
const res = await fetch(`${getApiUrl()}/tag`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error('Failed to create tag');
return res.json();
}
TanStack Query Hooks
Wrap fetchers in custom hooks under apps/web/src/hooks/queries/<feature>/ (for reads) or apps/web/src/hooks/mutations/<feature>/ (for writes). This provides caching, background updates, and error handling.
// apps/web/src/hooks/mutations/tag/use-create-tag.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTag } from '@/fetchers/tag/create-tag';
export function useCreateTag() {
const qc = useQueryClient();
return useMutation({
mutationFn: createTag,
onSuccess: () => {
// Invalidate tag list queries so UI updates
qc.invalidateQueries({ queryKey: ['tags'] });
},
});
}
UI Components
Integrate hooks into your React components. Kaneo uses a file-based router in apps/web/src/routes/ for page components, with shared UI components in apps/web/src/components/.
// apps/web/src/components/tag/TagForm.tsx
import { useState } from 'react';
import { useCreateTag } from '@/hooks/mutations/tag/use-create-tag';
import { toast } from 'sonner';
export function TagForm() {
const [name, setName] = useState('');
const { mutate, isLoading } = useCreateTag();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutate(name, {
onSuccess: () => {
toast.success('Tag created');
setName('');
},
onError: () => toast.error('Failed to create tag'),
});
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Tag name"
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>Add Tag</button>
</form>
);
}
Testing and Quality Assurance
Unit tests live alongside the code in *.test.ts files within both the API and web packages. Before committing, ensure your code passes the project's quality gates:
pnpm lint
pnpm typecheck
All code must follow the project's Biome formatting rules (double quotes, semicolons) and import order conventions (external → internal → relative) as specified in CLAUDE.md. Commit using Conventional Commits (e.g., feat: add tag API) according to the CONTRIBUTING.md guidelines.
Summary
- Kaneo is a pnpm monorepo with clear separation between
apps/apiandapps/web. - Database changes require updates to
apps/api/src/database/schema.tsfollowed by migration generation. - Backend features use Hono routes in
apps/api/src/<feature>/index.tswith Valibot validation. - Business logic belongs in
apps/api/src/<feature>/controllers/for maintainability. - Frontend data fetching uses typed fetchers in
apps/web/src/fetchers/<feature>/. - Server state is managed through TanStack Query hooks in
apps/web/src/hooks/. - All commits must follow Conventional Commits and pass
pnpm lintandpnpm typecheck.
Frequently Asked Questions
Where do I define new database tables in Kaneo?
New tables are defined in apps/api/src/database/schema.ts using Drizzle ORM syntax. After adding your table definition, run pnpm --filter @kaneo/api db:generate to create the migration file. This follows the infrastructure-as-code pattern used throughout the kaneo monorepo.
How does the frontend communicate with the backend API?
The frontend uses typed fetcher functions stored in apps/web/src/fetchers/<feature>/ that wrap standard fetch calls. These fetchers are then consumed by TanStack Query hooks in apps/web/src/hooks/ to provide React components with cached, reactive data access. This architecture ensures type safety from the API endpoint to the UI component.
What validation library does Kaneo use for API inputs?
Kaneo uses Valibot for runtime validation, applied through the validator middleware from hono-openapi in route definitions. You define schemas (e.g., v.object({ name: v.string() })) that automatically validate incoming JSON before your controller logic executes, preventing invalid data from reaching the database.
How do I run database migrations after modifying the schema?
Use the pnpm filter command to run the database generation script specifically in the API package: pnpm --filter @kaneo/api db:generate. This creates the migration files based on your schema changes. Apply these migrations to your development database before testing the new feature endpoints.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →