Skip to content

Latest commit

 

History

History
767 lines (549 loc) · 17 KB

File metadata and controls

767 lines (549 loc) · 17 KB

AGENTS.md

This file provides guidance to AI coding agents (Claude Code, Cursor, Copilot, etc.) when working with code in this repository.

Repository Overview

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/ui package

Quick Reference

Directory Structure

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

Rule Categories by Priority

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-

1. React 19 Patterns

Impact: CRITICAL

React 19 introduces new hooks and patterns that replace legacy patterns. Using modern patterns improves performance, reduces complexity, and enables better streaming.

1.1 Use use() Instead of useEffect for Data

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>

1.2 Use useOptimistic() for Immediate Feedback

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>;
}

1.3 Use useActionState() for Form Actions

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>
  );
}

1.4 Use useTransition() for Non-Urgent Updates

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>
  );
}

1.5 Always Wrap Async Components with Suspense

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>
  );
}

2. Next.js 16 App Router

Impact: CRITICAL

Next.js 16 with App Router requires specific patterns for routing, data fetching, and type safety.

2.1 Use Async Route Props

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>;
}

2.2 Use Server Components by Default

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

2.3 Define Server Actions in Separate Files

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>;
}

2.4 Always Generate Metadata

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,
    },
  };
}

3. Multi-Tenant Architecture

Impact: HIGH

This project uses subdomain-based multi-tenancy with organization slugs.

3.1 Validate Tenant Access in Layouts

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>
  );
}

3.2 Use Subdomain Routing for Tenants

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;
}

4. Type Safety

Impact: HIGH

Strict TypeScript usage prevents runtime errors and improves developer experience.

4.1 Never Use any or unknown

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 { }

4.2 Infer Types from Schemas

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>;

4.3 Centralize Type Definitions

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';

5. Security

Impact: HIGH

Security rules are mandatory for all code changes.

5.1 Always Validate All Inputs

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
}

5.2 Check Authentication AND Authorization

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));
}

5.3 Never Expose Secrets to Client

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';

6. Sanity CMS

Impact: MEDIUM-HIGH

Sanity is used for content management with visual editing support.

6.1 Define GROQ Queries with Types

import { defineQuery } from 'next-sanity';

export const pageQuery = defineQuery(`
  *[_type == "page" && slug.current == $slug][0] {
    _id,
    title,
    "slug": slug.current,
    sections[]
  }
`);

6.2 Include Data Attributes for Visual Editing

import { studioDataAttribute } from '@/sanity/dataAttribute';

function Section({ section, documentId }: Props) {
  return (
    <section {...studioDataAttribute(documentId, 'sections', section._key)}>
      {/* content */}
    </section>
  );
}

7. Better Auth

Impact: MEDIUM-HIGH

Better Auth handles all authentication with cross-subdomain cookie support.

7.1 Use Server-Side Session Checks

import { auth } from '@/lib/auth';
import { headers } from 'next/headers';

export async function getSession() {
  return auth.api.getSession({
    headers: await headers(),
  });
}

7.2 Use Auth Client in Client Components

'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} />;
}

8. Database (Drizzle)

Impact: MEDIUM

Drizzle ORM provides type-safe database access.

8.1 Define Schemas in packages/db

// 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(),
});

8.2 Use Query Builder Patterns

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();

9. API (Elysia/Eden)

Impact: MEDIUM

Elysia provides a type-safe API layer with Eden Treaty client.

9.1 Define Typed API Routes

// 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;

9.2 Use Eden Treaty Client

// 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();

10. Code Quality

Impact: MEDIUM

Consistent code quality ensures maintainability.

10.1 File Size Limits

  • Maximum 300-500 lines per file
  • Split large files into modules
  • One component per file

10.2 No Incomplete Code

  • NEVER leave TODO comments
  • NEVER use placeholder data
  • Implement features completely or not at all

10.3 SOLID, DRY, KISS

  • Single Responsibility: One purpose per function/component
  • DRY: Extract repeated code
  • KISS: Simple solutions over clever ones

References

  1. Next.js Documentation
  2. React 19 Documentation
  3. Sanity Documentation
  4. Better Auth Documentation
  5. Drizzle ORM Documentation
  6. Elysia Documentation

Full Instructions

For the complete development guidelines with all rules expanded, see: .github/copilot-instructions.md

For individual rule files, see: skills/mtnp-best-practices/rules/