Instruction file imported from jaemil/yomelo (
.cursor/rules/web-guide.mdc). Copyright stays with the author.
Web App Development Guide
Next.js 16 warning: This version has breaking changes — APIs, conventions, and file structure may differ from your training data. Read the relevant guide in
node_modules/next/dist/docs/before writing any code. Heed deprecation notices.
Tech Stack
- Framework: Next.js (App Router) with SSR priority
- UI: shadcn + TailwindCSS
- Forms: react-hook-form + Zod + zodResolver
- RPC: oRPC (procedures, server actions, API routes)
- Auth: better-auth (email/password)
- DB: Prisma + PostgreSQL (in
@yomelo/db)
Directory Structure
web/src/
├── i18n/
│ ├── routing.ts # Locale definitions (en, de) + default locale
│ ├── request.ts # Server-side message loading per request
│ └── navigation.ts # Locale-aware Link, useRouter, usePathname, redirect
├── messages/
│ ├── en.json # English translations
│ └── de.json # German translations
├── proxy.ts # Next.js 16 proxy (locale detection + routing)
├── procedures/ # All oRPC procedure definitions (business logic)
├── routers/
│ └── index.ts # Cherry-picks which procedures to expose via /api/rpc
├── actions/
│ └── orpc.ts # .actionable() wrappers for client components
├── lib/
│ ├── env/
│ │ ├── server.ts # Zod-validated server env vars (DATABASE_URL, BETTER_AUTH_*)
│ │ └── client.ts # Zod-validated client env vars (NEXT_PUBLIC_*)
│ ├── user.ts # Cached user helpers (getCurrentUser, requireUser, requireCreator, requireBrand)
│ ├── orpc.ts # Base procedure builders (publicProcedure, authedProcedure, creatorProcedure, brandProcedure)
│ ├── orpc.server.ts # Server client for calling router procedures in server components
│ ├── auth.ts # better-auth server instance
│ ├── auth-client.ts # better-auth client
│ └── utils.ts # cn(), generateSlug()
├── components/
│ ├── language-switcher.tsx # Locale toggle button
│ └── ui/ # shadcn components
└── app/
├── layout.tsx # Root layout (fonts, html/body — no i18n)
├── [locale]/
│ ├── layout.tsx # Locale layout (NextIntlClientProvider, metadata)
│ └── (public)/ # Public pages (landing, social redirects)
└── api/
├── rpc/[[...rest]]/route.ts # HTTP endpoint (for React Native)
└── auth/[...all]/route.ts # better-auth handler
Creating a New Feature
1. Schema — packages/schemas/src/feature.ts
Define Zod schemas. Single source of truth for validation, shared by client forms and oRPC procedures across all apps.
import { z } from "zod";
export const createThingSchema = z.object({
name: z.string().min(1),
});
export type CreateThingInput = z.infer<typeof createThingSchema>;
Then export from packages/schemas/src/index.ts:
export * from "./feature";
2. Procedure — web/src/procedures/feature.ts
import { publicProcedure } from "@/lib/orpc";
import { createThingSchema } from "@yomelo/schemas/feature";
import { prisma } from "@yomelo/db";
import { ORPCError } from "@orpc/server";
export const createThing = publicProcedure
.input(createThingSchema)
.handler(async ({ input }) => {
return { id: "123" };
});
3. Decide: API + Action, or Action-only?
If the procedure needs an API endpoint (React Native will call it):
- Add it to
routers/index.ts— available at/api/rpc/...AND as a server action
If the procedure is action-only (only Next.js calls it):
- Do NOT add it to
routers/index.ts— stays internal, no HTTP exposure
4. Action wrapper — web/src/actions/orpc.ts
export const createThingAction = createThing.actionable({
context: {},
});
5. Calling procedures
Server components (direct call, no network):
// For procedures in the router:
const data = await server.feature.list();
// For action-only procedures:
import { getCount } from "@/procedures/feature";
const count = await getCount.callable({ context: {} })();
Client components (via server action):
const [isPending, startTransition] = useTransition();
function onSubmit(values: MyInput) {
startTransition(async () => {
const [error, data] = await createThingAction(values);
if (error) {
toast.error(error.message);
return;
}
toast.success("Done");
});
}
React Native (via HTTP):
Calls /api/rpc/... — only procedures added to routers/index.ts are accessible.
Environment Variables
Env vars are validated with Zod at startup. Never use process.env directly.
- Server:
import { envServer } from "@/lib/env/server"—DATABASE_URL,BETTER_AUTH_SECRET,BETTER_AUTH_URL - Client:
import { envClient } from "@/lib/env/client"—NEXT_PUBLIC_*vars .envlocation:web/.env(also loaded by@yomelo/dbviaprisma.config.ts)
When adding a new env var:
- Add it to
web/.env - Add it to the appropriate Zod schema (
server.tsorclient.ts) - For client vars: also add explicit
process.env.NEXT_PUBLIC_*mapping in thesafeParsecall - Import from
envServerorenvClient— neverprocess.env
Parallel Routes
Always use Next.js parallel routes (@slot folders) for new dashboard features. They enable independent streaming and loading.tsx skeletons per section.
Top-level: role-based routing
dashboard/layout.tsx receives @creator and @brand slots and renders the correct one based on user.type.
Page-level: section slots
Within a page, split independent sections into their own @slot folder. The parent layout.tsx receives them as props and composes the page.
@creator/campaigns/
├── @list/
│ ├── page.tsx # async server component (fetches data)
│ └── loading.tsx # skeleton shown while streaming
├── @stats/
│ ├── page.tsx
│ └── loading.tsx
└── layout.tsx # composes: {list} {stats}
Rules
- Every
@slotneeds:page.tsx+loading.tsx(skeleton matching page layout) default.tsxis required at boundaries where a slot might not match. Re-exportpage.tsx:export { default } from "./page";page.tsxis anasyncserver component — fetch data directly, nouseEffectloading.tsxuses shadcn<Skeleton />components matching the page structure- Layout receives slots as props:
export default function Layout({ list, stats }: { list: React.ReactNode; stats: React.ReactNode }) { return <div>{list}{stats}</div>; } - Sidebars live inside each role slot — e.g.
@creator/creator-sidebar.tsx
User Type Checks
Users are either "creator" or "brand". Two layers handle this:
Server components — @/lib/user
All functions are React cache() backed — one DB query per request no matter how many components call them.
| Function | Returns |
|---|---|
getCurrentUser() |
User with type, or null |
requireUser() |
User — redirects to /login if unauthenticated |
requireCreator() |
User narrowed to { type: "creator" } — redirects if wrong type |
requireBrand() |
User narrowed to { type: "brand" } — redirects if wrong type |
import { requireUser, requireCreator } from "@/lib/user";
const user = await requireUser(); // guaranteed non-null
const creator = await requireCreator(); // guaranteed creator
Procedures — @/lib/orpc
Use creatorProcedure or brandProcedure instead of authedProcedure when the procedure is role-specific. They throw FORBIDDEN automatically and add context.userType (typed as "creator" or "brand").
import { creatorProcedure } from "@/lib/orpc";
export const createCampaign = creatorProcedure
.input(createCampaignSchema)
.handler(async ({ input, context }) => {
// context.userType is "creator" — type-safe and guaranteed
});
Important
- Server components/layouts: use
requireUser/requireCreator/requireBrandfrom@/lib/user - Procedures: use
creatorProcedure/brandProcedurefrom@/lib/orpc - Never check
user.typemanually in procedures — use the typed procedure instead redirectfor auth guards usesnext/navigation(not@/i18n/navigation) since auth redirects don't need locale prefixing
Procedures
- Keep procedures pure — no Next.js-specific APIs (
revalidatePath,cookies,redirect). Those belong in the action layer (actions/orpc.ts) or inlib/actions.tsviawithRevalidate. - Use early returns — validate and bail out at the top, keep the happy path unindented:
export const updateThing = authedProcedure
.input(updateThingSchema)
.handler(async ({ input, context }) => {
const thing = await prisma.thing.findUnique({ where: { id: input.id } });
if (!thing) throw new ORPCError("NOT_FOUND", { message: "Thing not found" });
if (thing.ownerId !== context.user.id) throw new ORPCError("FORBIDDEN");
return prisma.thing.update({ where: { id: input.id }, data: input });
});
Pagination
See pagination.mdc for the full pagination guide.
Rate Limiting
See rate-limiting.mdc for how to apply rate limiting.
Slugs
Use generateSlug from @/lib/utils when creating records that need a unique slug (campaigns, blog posts, linked accounts). It slugifies the input string and recursively appends a numeric suffix until the slug is unique in the given model's table.
import { generateSlug } from "@/lib/utils";
const slug = await generateSlug(input.title, "campaign");
Supported models: "campaign", "blogPost", "linkedAccount". To add a new model, update the union type and add the corresponding prisma.model.findUnique branch in generateSlug.
Client-Side Patterns
- Use
useTransitionfor loading state (notuseState) - Use
LoadingButtonfromcomponents/ui/loading-button.tsxwithisLoading={isPending} - Use
react-hook-formwithzodResolver— import the schema from@yomelo/schemas .actionable()returns[error, data]tuple — always check error first- Use
toastfromsonnerfor success/error feedback
i18n (Internationalization)
Locales: en (English) and de (German, default). Powered by next-intl.
Routing
All pages live under src/app/[locale]/. URLs are /de/... and /en/.... Visiting / redirects to /de/. API routes (/api/...) have no locale prefix.
Key Files
src/i18n/routing.ts— defineslocalesanddefaultLocalesrc/i18n/request.ts— loads the correct JSON messages per requestsrc/i18n/navigation.ts— locale-awareLink,useRouter,usePathname,redirect,getPathnamesrc/proxy.ts— Next.js 16 proxy that runsnext-intllocale detection/rewritingsrc/messages/en.json,de.json— namespaced translation dictionaries
Using Translations
Server components:
import { getTranslations } from "next-intl/server";
export default async function Page() {
const t = await getTranslations("namespace");
return <h1>{t("key")}</h1>;
}
Client components:
"use client";
import { useTranslations } from "next-intl";
export function MyComponent() {
const t = useTranslations("namespace");
return <p>{t("key")}</p>;
}
Interpolation: {variable} syntax in JSON, pass values as second arg:
t("spotsLeft", { spotsLeft: 42, totalSpots: 100 });
Metadata:
export async function generateMetadata({ params }) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "meta" });
return { title: t("title") };
}
Adding New Translations
- Add keys to both
src/messages/en.jsonandsrc/messages/de.json - Use namespaced top-level keys (e.g.
"dashboard","settings") - Access via
getTranslations("namespace")oruseTranslations("namespace")
Navigation
Always use locale-aware navigation from @/i18n/navigation instead of next/link or next/navigation:
import { Link, useRouter, redirect } from "@/i18n/navigation";