This project is a monorepo managed by pnpm and structured into apps and packages.
nn-stack/
├── apps/
│ ├── server/ # Backend Hono server
│ └── web/ # Frontend Next.js application
└── packages/
├── api/ # Shared API interfaces (orpc)
├── db/ # Drizzle database
├── config/ # Common configurations
└── ui/ # Shadcn UI components
apps/server: A backend application useshonoand@orpc/server. Automatically handle APIs defined inpackages/api. Deployment is handled viaalchemy.run.apps/web: A frontend web application built with Next.js. It interacts with the backend services. Deployment is handled viaalchemy.run.packages/api: A shared package defining the API interfaces and types. Usesorpcto implement end-to-end type-safe APIs, validate by using zod, shared between web and server.packages/config: A shared package for common configurations.packages/ui: OriginalshadcnUI components will be installed here for use by other packages. These components should not be modified.packages/db: Code related to Drizzle Cloudflare D1 database, including schema definitions for database tables.
| Category | Technology |
|---|---|
| Package Manager | pnpm |
| Frontend | Next.js, React Query |
| Backend | Hono |
| API Layer | ORPC, Zod |
| UI Library | Shadcn/ui, Radix UI |
| Styling | Tailwind CSS V4 |
| Code Quality | Biome |
| Deployment | Alchemy.run |
This project uses pnpm for package management.
To install all dependencies across the monorepo:
pnpm installTo start the development servers for all applications:
pnpm run devThis project uses Biome for linting and formatting.
- To lint the entire project and write fixes:
pnpm run lint
- To format the entire project and write fixes:
pnpm run format
After each code modification, you should run pnpm run lint to ensure code quality, and fix any lint errors again.
This project uses alchemy for deployment.
- Deploy to development environment:
pnpm run deploy:dev
- Deploy to production environment:
pnpm run deploy:prod
- General deploy (may require additional configuration):
pnpm run deploy
- Destroy development environment deployment:
pnpm run destroy:dev
- Destroy production environment deployment:
pnpm run destroy:prod
- General destroy (may require additional configuration):
pnpm run destroy
For local development, we use .dev.env in apps/web and apps/server respectively and set the following values:
apps/web/.dev.env:NEXT_PUBLIC_SERVER_URL=http://localhost:4000(Points to the local Hono server).apps/server/.dev.env:CORS_ORIGIN=http://localhost:3000,http://localhost:3001(Allows requests from the local Next.js app).
Important Note on Adding New Environment Variables:
- Do NOT manually edit
env.d.ts: These files are auto-generated based on the bindings and configurations inalchemy.run.ts. - Edit
apps/server/alchemy.run.tsfor server or editapps/web/alchemy.run.tsfor client: To add a new environment variable or binding:- Locate the
bindingsobject within theWorkerconfiguration. - Add your new variable there (e.g.,
MY_VAR: process.env.MY_VAR || ''). - If using
process.env, ensure the variable is defined inapps/server/.dev.envorapps/web/.dev.envfor local development.
- Locate the
- Run Dev Server: Starting the development server (
pnpm dev) will automatically regenerateapps/server/env.d.tsto reflect your changes, providing type safety. - Update
.env.exampleto add an example
- Define Schema: We use Drizzle together with the D1 database, so we use the
drizzle-orm/sqlite-corepackage to define the table schema, Be sure to import the type correctly. The schema definition file is saved inpackages/db/src/schema.ts. - Generate Migrations: After changing schemas in
packages/db/src/schema.ts, runpnpm run db:generatefrom thepackages/dbdirectory to generate migrations. - Apply Migrations: Since we use Alchemy.run to compile and run the program, when
pnpm devis executed, migrations are automatically applied in the local environment. When deploying, migrations are also automatically applied to the production environment.�� - Drizzle Usage: Drizzle has two modes: Relational Query API (
db.query...) and SQL-like API (db.select()...). We ONLY use the SQL-like API (db.select().from(table).where(...)). Do NOT use the Relational Query API.
- Custom SQL Migrations: When you need to write manual SQL (e.g., for data migration, cleaning, or complex operations not supported by the schema builder), you MUST follow this workflow:
- Run
pnpm exec drizzle-kit generate --custom --name=<migration_name>(in thepackages/dbdirectory) to generate an empty migration file. This ensures the migration is correctly registered in the_journal.json. - Populate the generated
.sqlfile with your custom SQL commands. - NEVER manually create a
.sqlfile in the migrations folder or manually edit_journal.json.
- Run
- Monorepo Management: pnpm workspaces are used to manage multiple packages within a single repository.
- Code Style: Enforced by Biome (linting and formatting).
- API Definition: The
@nn-stack/apipackage defines the shared API contracts. - Database Schema Definition: The
@nn-stack/db/src/schema.tsfile is used to define the d1 database table structure. - UI Components: Reusable UI components are developed in the
@nn-stack/uipackage.
Must comply with Next.js Hono oRPC best practices
- If the component is a client component, don't forget to add 'use client'
- If the page needs UI components, don't use native browser components. Must develop based on Shadcn UI components, imported from
@nn-stack/ui. Restore the design to the maximum extent possible. If there are issues, you can use Shadcn's MCP tool. - Only use tailwindcss V4 for styling. CSS inline styles are not allowed. Follow tailwindcss v4 built-in responsive design rules and mobile-first principles.
- If there are multiple ways to implement layout, prefer using grid or the most concise implementation method
- When components need icons, only use icons provided in
lucide-react, no SVG allowed. - If interaction with the backend is needed, use TanStack Query V5. Try not to use React Context API
- Don't over-optimize, don't add meaningless
useMemoanduseCallback, especially don't adduseMemoto data returned by Tanstack Query API hooks - The
cnutility function must be imported from@nn-stack/ui/lib/utils. Do not create a locallib/utils.tsor import from@/lib/utils. - All text in the interface should be in English
- If importing other components, use
@/absolute path imports - Note that all code comments should be in English. Don't write obviously meaningless comments, and don't easily delete existing comments in the code
- No
anyType: The usage ofanyis strictly prohibited. Useunknownwith type narrowing, or define explicit interfaces/types. If a library type is difficult to access, define a local compatible interface. Do not useas anycasting. - Error Handling Best Practices: In
try-catchblocks, the catch variable isunknownby default. Do not cast it toany.- Use
if (error instanceof Error)to narrow the type before accessing.message. - If the error structure is unknown, fallback to a generic error message.
- Example:
try { // ... } catch (error: unknown) { if (error instanceof Error) { console.error(error.message); } else { console.error("An unknown error occurred"); } }
- Use
To ensure a high-quality, professional, and consistent user interface, all UI development should adhere to the principles from "Refactoring UI" by Adam Wathan and Steve Schoger. These principles are designed to be implemented with Tailwind CSS.
- Use a Predefined Scale: All margins, padding, widths, and heights should use the default Tailwind CSS spacing scale (which is based on a base unit of
0.25remor4px). Avoid arbitrary values (e.g.,margin-top: 13px). This creates a more harmonious and rhythmic design. - Space is a Separator: Instead of relying heavily on borders, use empty space (padding and margins) to group related elements and separate distinct ones.
- Start with a Limited Palette: Don't define colors haphazardly. Establish a clear, constrained palette:
- Grays/Neutrals: A set of 5-10 neutral grays for text, backgrounds, and subtle borders. Tailwind's
slate,gray,zinc,neutral,stonescales are perfect for this. - Primary Color: One or two primary brand colors for key actions (buttons, links, active states).
- Semantic Colors: Dedicated colors for success (green), warning (yellow/orange), and error (red) states.
- Grays/Neutrals: A set of 5-10 neutral grays for text, backgrounds, and subtle borders. Tailwind's
- Use Color with Purpose: Use your primary color for primary actions only. Overusing it diminishes its impact. Most text should be a dark gray (e.g.,
text-slate-800), not pure black (#000), to be easier on the eyes.
- Establish a Typographic Scale: Use Tailwind's font-size scale (e.g.,
text-xs,text-sm,text-base,text-lg,text-xl). Don't use arbitrary font sizes. - Limit Font Weights: Stick to a few font weights (e.g.,
font-normal,font-medium,font-semibold). - Hierarchy through Contrast: Create a clear visual hierarchy not just with size, but with font weight and color. For example, a section title can be the same size as body text but with a heavier weight (
font-semibold) and darker color. - Line Height Matters: Use Tailwind's leading utilities (e.g.,
leading-normal,leading-relaxed) to ensure text is readable.
- Use Shadows for Elevation: Use shadows to lift interactive elements (like cards and dialogs) off the page. Use subtle shadows. Tailwind's
shadow-sm,shadow-md,shadow-lgprovide a great starting point. - Fewer Borders: Borders can make a design feel busy. Prefer using box shadows or different background colors (
bg-slate-100) to create separation between elements. When you must use a border, make it subtle (e.g.,border-slate-200).
- Buttons: Design clear primary (solid background), secondary (outline or lighter background), and tertiary (ghost/text-only) button styles. Ensure they have clear
hoverandfocusstates. - Forms:
- Place labels above their corresponding inputs.
- Use a consistent height for all form controls (inputs, selects, buttons).
- Provide highly visible
focusstates (e.g., a blue ring usingfocus:ring-2) to improve accessibility.
- Icons: Use icons from a single family (e.g.,
lucide-reactas specified) and ensure they are consistently styled (e.g., all outline or all solid). Always pair an icon with text unless the meaning is universally understood (like a close 'X').
- Provide reasonable file naming
- File names must be named in lowercase snake case
- File names should never have
_, use-instead
- Generate complete Next.js component code. If you have save permissions, please save the file in the
apps/web/components/folder under the corresponding component name- For example, when generating a
Logincomponent, it should be saved asapps/web/components/login/index.tsx
- For example, when generating a
- Also generate usage examples of the component in the
apps/web/app/playground/components/folder under the correspondingcomponent name- For example, when generating a
Logincomponent, it should be saved asapps/web/app/playground/components/login/page.tsx
- For example, when generating a
- Complex JSX Comments: Complex JSX structures MUST have English comments to clearly separate and identify different UI sections. This makes it easier for humans to visually distinguish blocks (e.g.,
{/* Header Section */},{/* Main Content */}). All comments must be in English. - You can try to remind users to optimize meaningless
useMemoanduseCallbackin the code
- You can only install shadcn component by using
pnpm dlx shadcn@latest add <component> -c packages/uicommand in root folder,-c packages/uimeans install the component into@nn-stack/ui, for example:pnpm dlx shadcn@latest add checkbox -c packages/ui. - Note: use
shadcn@latest, notshadcn-ui@latest - Never modify code in
@nn-stack/ui
When importing Shadcn UI components from @nn-stack/ui in apps/web:
- Correct:
import { Button } from '@nn-stack/ui/components/button' - Incorrect:
import { Button } from '@nn-stack/ui/button' - The
tsconfig.jsonpath mapping@nn-stack/ui/*points topackages/ui/src/*, and components are located inpackages/ui/src/components/.
When a page in apps/web needs a UI component:
- First, check if it exists in
@nn-stack/ui. - If it does not exist, use the command
pnpm dlx shadcn@latest add <component> -c packages/uito add it to the ui package. - Finally, import and use it from
@nn-stack/uiin the code ofapps/web.
- Dialog Width:
DialogContentcomponent in@packages/uihas a defaultsm:max-w-lgclass. To set a wider width (e.g.,max-w-4xl), you MUST add thesm:prefix (e.g.,sm:max-w-4xl) to override the default behavior.
Before starting actual development, make sure to understand the latest versions of Tanstack Query, Drizzle, Zod v4 and oRPC. Feel free to use the context7 MCP server to query the latest documentation.
API development is end-to-end type safe by using oRPC.
In general, there is no need to modify the hono code in @nn-stack/apps/server, because hono has already integrated oRPC. You only need to create the corresponding oRPC API.
On the server side, use ORPC to define server APIs in the @nn-stack/packages/api/src subdirectory, and import them into the API entry point.
The API entry point is located at apps/packages/api/src/index.ts.
On the client side, use TanStack React Query in @nn-stack/apps/web to call the corresponding APIs, for example:
const connectionCheck = useQuery(orpc.healthCheck.connection.queryOptions());
APIs development are based on ORPC With Tanstack Query Integration, should comply with their best practices
Tanstack Query is a robust solution for asynchronous state management. oRPC Tanstack Query integration is very lightweight and straightforward
Use .queryOptions to configure queries. Use it with hooks like useQuery, useSuspenseQuery, or prefetchQuery.
const query = useQuery(
orpc.planet.find.queryOptions({
input: { id: 123 }, // Specify input if needed
context: { cache: true }, // Provide client context if needed
// additional options...
}),
);Use .mutationOptions to create options for mutations. Use it with hooks like useMutation.
const mutation = useMutation(
orpc.planet.create.mutationOptions({
context: { cache: true }, // Provide client context if needed
// additional options...
}),
);
mutation.mutate({ name: "Earth" });oRPC provides a set of helper methods to generate keys for queries and mutations:
.key: Generate a partial matching key for actions like revalidating queries, checking mutation status, etc..queryKey: Generate a full matching key for Query Options..streamedKey: Generate a full matching key for Streamed Query Options..infiniteKey: Generate a full matching key for Infinite Query Options..mutationKey: Generate a full matching key for Mutation Options.
const queryClient = useQueryClient();
// Invalidate all planet queries
queryClient.invalidateQueries({
queryKey: orpc.planet.key(),
});
// Invalidate only regular (non-infinite) planet queries
queryClient.invalidateQueries({
queryKey: orpc.planet.key({ type: "query" }),
});
// Invalidate the planet find query with id 123
queryClient.invalidateQueries({
queryKey: orpc.planet.find.key({ input: { id: 123 } }),
});
// Update the planet find query with id 123
queryClient.setQueryData(
orpc.planet.find.queryKey({ input: { id: 123 } }),
(old) => {
return { ...old, id: 123, name: "Earth" };
},
);Use .call to call a procedure client directly. It's an alias for corresponding procedure client.
const planet = await orpc.planet.find.call({ id: 123 });::: warning
oRPC excludes client context from query keys. Manually override query keys if needed to prevent unwanted query deduplication. Use built-in retry option instead of the oRPC Client Retry Plugin.
const query = useQuery(
orpc.planet.find.queryOptions({
context: { cache: true },
queryKey: [["planet", "find"], { context: { cache: true } }],
retry: true, // Prefer using built-in retry option
// additional options...
}),
);:::
Easily manage type-safe errors using our built-in isDefinedError helper.
import { isDefinedError } from "@orpc/client";
const mutation = useMutation(
orpc.planet.create.mutationOptions({
onError: (error) => {
if (isDefinedError(error)) {
// Handle type-safe error here
}
},
}),
);
mutation.mutate({ name: "Earth" });
if (mutation.error && isDefinedError(mutation.error)) {
// Handle the error here
}::: info For more details, see our type-safe error handling guide. :::
The skipToken symbol offers a type-safe alternative to the disabled option when you need to conditionally disable a query by omitting its input.
const query = useQuery(
orpc.planet.list.queryOptions({
input: search ? { search } : skipToken, // [!code highlight]
}),
);
const query = useInfiniteQuery(
orpc.planet.list.infiniteOptions({
input: search // [!code highlight]
? (offset: number | undefined) => ({ limit: 10, offset, search }) // [!code highlight]
: skipToken, // [!code highlight]
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextPageParam,
}),
);暂时无需执行测试