This file provides guidance to AI coding agents (Claude Code, Cursor, Copilot, etc.) when working with code in this repository.
MTNP is a multi-tenant Next.js platform using a monorepo structure with Turborepo. The project combines modern web technologies for building scalable, multi-tenant SaaS applications.
Tech Stack:
- Frontend: Next.js 16+ with App Router, React 19, Tailwind CSS 4
- CMS: Sanity v5 with Visual Editing & GROQ queries
- Backend: Elysia (Bun framework) with Eden Treaty
- Auth: Better Auth for authentication
- Database: Drizzle ORM with PostgreSQL
- UI: Custom shadcn/ui components in
@mtnp/uipackage
mtnp/
├── apps/
│ └── web/ # Next.js 16 App (main application)
│ ├── src/
│ │ ├── app/ # App Router pages & layouts
│ │ │ ├── (auth)/ # Auth routes (sign-in, sign-up, onboarding)
│ │ │ ├── [orgSlug]/ # Multi-tenant org routes
│ │ │ ├── admin/ # Admin dashboard
│ │ │ └── api/ # API routes (Elysia)
│ │ ├── components/ # React components
│ │ ├── lib/ # Utilities, clients, auth
│ │ ├── sanity/ # Sanity configuration
│ │ └── types/ # TypeScript definitions
├── packages/
│ ├── db/ # @mtnp/db - Drizzle ORM
│ ├── sanity/ # @mtnp/sanity - Sanity schemas
│ ├── ui/ # @mtnp/ui - UI components
│ └── typescript-config/ # Shared TS configs
└── skills/ # Agent skills & rules
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | React 19 Patterns | CRITICAL | react- |
| 2 | Next.js 16 App Router | CRITICAL | nextjs- |
| 3 | Multi-Tenant Architecture | HIGH | tenant- |
| 4 | Type Safety | HIGH | types- |
| 5 | Security | HIGH | security- |
| 6 | Sanity CMS | MEDIUM-HIGH | sanity- |
| 7 | Better Auth | MEDIUM-HIGH | auth- |
| 8 | Database (Drizzle) | MEDIUM | db- |
| 9 | API (Elysia/Eden) | MEDIUM | api- |
| 10 | Code Quality | MEDIUM | quality- |
Impact: CRITICAL
React 19 introduces new hooks and patterns that replace legacy patterns. Using modern patterns improves performance, reduces complexity, and enables better streaming.
Impact: CRITICAL (eliminates client-side waterfalls)
The use() hook resolves promises in client components, enabling Suspense integration.
Incorrect: legacy useEffect pattern
'use client';
import { useState, useEffect } from 'react';
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(setUser).finally(() => setLoading(false));
}, [userId]);
if (loading) return <Skeleton />;
return <div>{user?.name}</div>;
}Correct: use() with Suspense
'use client';
import { use } from 'react';
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise);
return <div>{user.name}</div>;
}
// Parent wraps with Suspense
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={fetchUser(id)} />
</Suspense>Impact: HIGH (instant UI response)
Optimistic updates show immediate feedback while server actions complete.
Incorrect: waiting for server response
'use client';
function LikeButton({ postId, liked }: Props) {
const [isLiked, setIsLiked] = useState(liked);
const [isPending, setIsPending] = useState(false);
async function handleLike() {
setIsPending(true);
await likePost(postId);
setIsLiked(!isLiked);
setIsPending(false);
}
return <button disabled={isPending}>{isLiked ? '❤️' : '🤍'}</button>;
}Correct: optimistic updates
'use client';
import { useOptimistic } from 'react';
function LikeButton({ postId, liked, onLike }: Props) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked);
async function handleLike() {
setOptimisticLiked(!optimisticLiked);
await onLike(postId);
}
return <button onClick={handleLike}>{optimisticLiked ? '❤️' : '🤍'}</button>;
}Impact: HIGH (simplified form handling)
Combines form action with pending and error states.
Incorrect: manual form state management
'use client';
function ContactForm() {
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setIsPending(true);
const result = await submitForm(new FormData(e.target));
if (result.error) setError(result.error);
setIsPending(false);
}
return <form onSubmit={handleSubmit}>...</form>;
}Correct: useActionState
'use client';
import { useActionState } from 'react';
import { submitForm } from './actions';
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitForm, null);
return (
<form action={formAction}>
<input name="email" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{state?.error && <p className="error">{state.error}</p>}
</form>
);
}Impact: MEDIUM (maintains UI responsiveness)
Mark non-urgent state updates as transitions to keep UI responsive.
Incorrect: blocks UI on every keystroke
function SearchInput({ onSearch }: Props) {
function handleChange(e: ChangeEvent<HTMLInputElement>) {
onSearch(e.target.value);
}
return <input onChange={handleChange} />;
}Correct: non-blocking transitions
'use client';
import { useTransition } from 'react';
function SearchInput({ onSearch }: Props) {
const [isPending, startTransition] = useTransition();
function handleChange(e: ChangeEvent<HTMLInputElement>) {
startTransition(() => onSearch(e.target.value));
}
return (
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<input onChange={handleChange} />
</div>
);
}Impact: CRITICAL (enables streaming)
Every async component must be wrapped with Suspense for proper streaming.
Incorrect: no Suspense boundary
export default function Page() {
return (
<div>
<Header />
<AsyncContent /> {/* Will block entire page */}
<Footer />
</div>
);
}Correct: Suspense boundaries
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<ContentSkeleton />}>
<AsyncContent />
</Suspense>
<Footer />
</div>
);
}Impact: CRITICAL
Next.js 16 with App Router requires specific patterns for routing, data fetching, and type safety.
Impact: CRITICAL (prevents runtime errors)
In Next.js 16+, params and searchParams are Promises that must be awaited.
Incorrect: synchronous params access
export default function Page({ params }: { params: { slug: string } }) {
return <div>{params.slug}</div>;
}Correct: async params
import type { PageProps } from '@/types/next';
interface Params {
slug: string;
}
export default async function Page({ params }: PageProps<Params>) {
const { slug } = await params;
return <div>{slug}</div>;
}Impact: HIGH (smaller bundles, better performance)
Only add 'use client' when browser APIs or interactivity are required.
When to use Server Components (default):
- Fetching data
- Rendering static content
- Accessing backend resources
- Server-only code (secrets, database)
When to use Client Components:
- Event handlers with state (onClick, onChange)
- Browser APIs (localStorage, window)
- React 19 hooks (useOptimistic, useTransition)
- Third-party client libraries
Impact: MEDIUM (better organization, tree-shaking)
Incorrect: inline server actions
export default function Page() {
async function submitForm(formData: FormData) {
'use server';
// ...
}
return <form action={submitForm}>...</form>;
}Correct: separate actions file
// app/actions.ts
'use server';
export async function submitForm(formData: FormData) {
// ...
}
// app/page.tsx
import { submitForm } from './actions';
export default function Page() {
return <form action={submitForm}>...</form>;
}Impact: HIGH (SEO, social sharing)
Every page should have appropriate metadata.
import { Metadata } from 'next';
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { orgSlug } = await params;
const org = await getOrganization(orgSlug);
return {
title: org.name,
description: org.description,
openGraph: {
title: org.name,
description: org.description,
},
};
}Impact: HIGH
This project uses subdomain-based multi-tenancy with organization slugs.
Impact: CRITICAL (security)
Always verify the tenant exists and user has access in the layout.
// app/[orgSlug]/layout.tsx
import type { LayoutProps } from '@/types/next';
export default async function OrgLayout({
children,
params
}: LayoutProps<{ orgSlug: string }>) {
const { orgSlug } = await params;
const org = await getOrganization(orgSlug);
if (!org) notFound();
return (
<OrgProvider org={org}>
{children}
</OrgProvider>
);
}Impact: HIGH (clean URLs)
Tenant sites are accessed via subdomain: {org-slug}.lvh.me:3000
// lib/tenant.ts
export function extractOrgSlug(hostname: string): string | null {
const parts = hostname.split('.');
if (parts.length >= 2 && parts[0] !== 'www') {
return parts[0];
}
return null;
}Impact: HIGH
Strict TypeScript usage prevents runtime errors and improves developer experience.
Impact: HIGH (prevents bugs)
Incorrect:
const data: any = response;
function process(input: any): any { }Correct:
const data: ApiResponse<Organization[]> = response;
function process(input: CreateOrgInput): Organization { }Impact: MEDIUM (reduces duplication)
Use type inference from Drizzle and Valibot schemas.
// From Drizzle
import { organizations } from '@mtnp/db/schema';
type Organization = typeof organizations.$inferSelect;
// From Valibot
import * as v from 'valibot';
const CreateOrgSchema = v.object({ name: v.string() });
type CreateOrgInput = v.InferInput<typeof CreateOrgSchema>;Impact: MEDIUM (consistency)
All shared types go in apps/web/src/types/.
// types/index.ts
export * from './api';
export * from './sanity';
export * from './forms';
export * from './next';
// Usage
import type { Organization, PageProps } from '@/types';Impact: HIGH
Security rules are mandatory for all code changes.
Impact: CRITICAL (prevents injection attacks)
'use server';
import * as v from 'valibot';
export async function updateUser(formData: FormData) {
const result = v.safeParse(UpdateUserSchema, Object.fromEntries(formData));
if (!result.success) {
return { error: 'Invalid input' };
}
// Only use result.output - NEVER raw formData
}Impact: CRITICAL (prevents unauthorized access)
export async function deleteOrganization(orgId: string) {
const session = await requireAuth();
const membership = await db.query.organizationMembers.findFirst({
where: and(
eq(organizationMembers.organizationId, orgId),
eq(organizationMembers.userId, session.user.id),
eq(organizationMembers.role, 'owner')
),
});
if (!membership) {
throw new Error('Unauthorized');
}
await db.delete(organizations).where(eq(organizations.id, orgId));
}Impact: CRITICAL (data breach prevention)
// ✅ Server Component - safe
export default async function Page() {
const data = await fetchWithApiKey(process.env.SECRET_KEY);
return <PublicData data={data.publicFields} />;
}
// ✅ Use server-only for sensitive modules
import 'server-only';Impact: MEDIUM-HIGH
Sanity is used for content management with visual editing support.
import { defineQuery } from 'next-sanity';
export const pageQuery = defineQuery(`
*[_type == "page" && slug.current == $slug][0] {
_id,
title,
"slug": slug.current,
sections[]
}
`);import { studioDataAttribute } from '@/sanity/dataAttribute';
function Section({ section, documentId }: Props) {
return (
<section {...studioDataAttribute(documentId, 'sections', section._key)}>
{/* content */}
</section>
);
}Impact: MEDIUM-HIGH
Better Auth handles all authentication with cross-subdomain cookie support.
import { auth } from '@/lib/auth';
import { headers } from 'next/headers';
export async function getSession() {
return auth.api.getSession({
headers: await headers(),
});
}'use client';
import { useSession } from '@/lib/auth-client';
function UserMenu() {
const { data: session, isPending } = useSession();
if (isPending) return <Skeleton />;
if (!session) return <SignInButton />;
return <UserDropdown user={session.user} />;
}Impact: MEDIUM
Drizzle ORM provides type-safe database access.
// packages/db/src/schema/organization.ts
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const organizations = pgTable('organizations', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});import { db } from '@mtnp/db';
import { eq } from 'drizzle-orm';
// Query
const org = await db.query.organizations.findFirst({
where: eq(organizations.slug, slug),
});
// Insert
const [newOrg] = await db.insert(organizations).values({ name }).returning();Impact: MEDIUM
Elysia provides a type-safe API layer with Eden Treaty client.
// app/api/[[...slugs]]/route.ts
import { Elysia, t } from 'elysia';
const app = new Elysia({ prefix: '/api' })
.get('/organizations', async () => {
return db.select().from(organizations);
})
.post('/organizations', async ({ body }) => {
return db.insert(organizations).values(body).returning();
}, {
body: t.Object({
name: t.String(),
slug: t.String(),
}),
});
export type App = typeof app;// lib/eden.ts
import { treaty } from '@elysiajs/eden';
import type { App } from '@/app/api/[[...slugs]]/route';
export const api = treaty<App>('http://localhost:3000');
// Fully typed!
const { data: orgs } = await api.api.organizations.get();Impact: MEDIUM
Consistent code quality ensures maintainability.
- Maximum 300-500 lines per file
- Split large files into modules
- One component per file
- NEVER leave TODO comments
- NEVER use placeholder data
- Implement features completely or not at all
- Single Responsibility: One purpose per function/component
- DRY: Extract repeated code
- KISS: Simple solutions over clever ones
- Next.js Documentation
- React 19 Documentation
- Sanity Documentation
- Better Auth Documentation
- Drizzle ORM Documentation
- Elysia Documentation
For the complete development guidelines with all rules expanded, see: .github/copilot-instructions.md
For individual rule files, see: skills/mtnp-best-practices/rules/