web-production-saas-starter/next_b2b_starter/docs/09-adding-a-feature.md
2025-12-16 18:54:41 +04:00

16 KiB

Adding a Feature

This guide shows you how to add a complete feature from start to finish.

Example: Add Vendor Management to the app.

Adding a Feature

This checklist guides you through adding a new feature (e.g., "Vendor Management").

1. Backend Layer

  1. Define Database Schema: Add tables (e.g., vendors) in your backend.
  2. Create API Endpoints: specific generic REST endpoints (GET /vendors, POST /vendors).
  3. Define Permissions: Add generic permissions in src/pkg/auth/rbac.go (e.g., vendor:view).

2. Frontend Data Layer

  1. Add Permissions: Update lib/auth/permissions.ts to match backend.
  2. Create Repository: Add lib/api/api/repositories/vendor-repository.ts.
  3. Create Hooks: Add useVendorsQuery and useCreateVendorMutation.

3. UI Layer

  1. Create Page: Add app/vendors/page.tsx (Server Component).
  2. Create Components: Build VendorList.tsx and VendorForm.tsx (Client Components).
  3. Add Navigation: Add link to app/dashboard/layout.tsx.

4. Verification Check

  • Auth: Can unauthenticated users access the page? (Should be NO)
  • Permissions: Can unauthorized roles see the page? (Should be NO)
  • Data: Does the list update after creating a new item?
  • Loading: Is there a loading state?

Summary Flow

graph TD
    A[Backend: DB & API] --> B[Frontend: Repository_Layer]
    B --> C[Frontend: React_Hooks]
    C --> D[Frontend: UI_Components]
    D --> E[Frontend: Next.js_Page]

Step 1: Define Requirements

What we need:

  • Users with vendor:view permission can see vendors
  • Users with vendor:create permission can add vendors
  • Users with vendor:edit permission can modify vendors
  • Users with vendor:delete permission can remove vendors
  • Only authenticated users can access vendor pages

Step 2: Add Permissions

2.1 Define Permissions

Edit lib/auth/permissions.ts:

export const PERMISSIONS = {
  // ... existing permissions

  // Vendor Management (ADD THESE)
  VENDOR_VIEW: "vendor:view",
  VENDOR_CREATE: "vendor:create",
  VENDOR_EDIT: "vendor:edit",
  VENDOR_DELETE: "vendor:delete",
} as const;

2.2 Update Server Permissions

Edit lib/auth/server-permissions.ts:

export interface ServerPermissions {
  // ... existing properties

  // Add these
  canViewVendors: boolean;
  canCreateVendors: boolean;
  canEditVendors: boolean;
  canDeleteVendors: boolean;
}

export async function getServerPermissions(session): Promise<ServerPermissions> {
  // ... existing code

  return {
    // ... existing returns

    // Add these
    canViewVendors: permissions.includes(PERMISSIONS.VENDOR_VIEW),
    canCreateVendors: permissions.includes(PERMISSIONS.VENDOR_CREATE),
    canEditVendors: permissions.includes(PERMISSIONS.VENDOR_EDIT),
    canDeleteVendors: permissions.includes(PERMISSIONS.VENDOR_DELETE),
  };
}

Step 3: Create Backend API Endpoints

Note: This assumes you have a backend API. If your backend doesn't have vendor endpoints yet, work with your backend team to create them.

Expected backend endpoints:

  • GET /vendors - List all vendors
  • GET /vendors/:id - Get vendor by ID
  • POST /vendors - Create vendor
  • PUT /vendors/:id - Update vendor
  • DELETE /vendors/:id - Delete vendor

Step 4: Create Repository

Create lib/api/api/repositories/vendor-repository.ts:

import { apiClient } from "../client/api-client";

export interface Vendor {
  id: string;
  name: string;
  email: string;
  phone?: string;
  address?: string;
  status: "active" | "inactive";
  created_at: string;
  updated_at: string;
}

class VendorRepository {
  async list(sessionToken?: string): Promise<Vendor[]> {
    const options = sessionToken
      ? { headers: { Authorization: `Bearer ${sessionToken}` } }
      : undefined;

    return apiClient.get<Vendor[]>("/vendors", options);
  }

  async get(id: string, sessionToken?: string): Promise<Vendor> {
    const options = sessionToken
      ? { headers: { Authorization: `Bearer ${sessionToken}` } }
      : undefined;

    return apiClient.get<Vendor>(`/vendors/${id}`, options);
  }

  async create(data: Omit<Vendor, "id" | "created_at" | "updated_at">): Promise<Vendor> {
    return apiClient.post<Vendor>("/vendors", data);
  }

  async update(id: string, data: Partial<Vendor>): Promise<Vendor> {
    return apiClient.put<Vendor>(`/vendors/${id}`, data);
  }

  async delete(id: string): Promise<void> {
    return apiClient.delete<void>(`/vendors/${id}`);
  }
}

export const vendorRepository = new VendorRepository();

Step 5: Create Query/Mutation Hooks

5.1 Create Query Hook

Create lib/hooks/queries/use-vendors-query.ts:

'use client';
import { useQuery } from '@tanstack/react-query';
import { vendorRepository } from '@/lib/api/api/repositories/vendor-repository';

export function useVendorsQuery() {
  return useQuery({
    queryKey: ['vendors'],
    queryFn: () => vendorRepository.list(),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

export function useVendorQuery(id: string) {
  return useQuery({
    queryKey: ['vendors', id],
    queryFn: () => vendorRepository.get(id),
    enabled: !!id,
  });
}

5.2 Create Mutation Hooks

Create lib/hooks/mutations/use-vendor-mutations.ts:

'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { vendorRepository } from '@/lib/api/api/repositories/vendor-repository';
import { toast } from 'sonner';

export function useCreateVendor() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: vendorRepository.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['vendors'] });
      toast.success('Vendor created successfully');
    },
    onError: (error: Error) => {
      toast.error(`Failed to create vendor: ${error.message}`);
    },
  });
}

export function useUpdateVendor() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: any }) =>
      vendorRepository.update(id, data),
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({ queryKey: ['vendors'] });
      queryClient.invalidateQueries({ queryKey: ['vendors', variables.id] });
      toast.success('Vendor updated successfully');
    },
    onError: (error: Error) => {
      toast.error(`Failed to update vendor: ${error.message}`);
    },
  });
}

export function useDeleteVendor() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: vendorRepository.delete,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['vendors'] });
      toast.success('Vendor deleted successfully');
    },
    onError: (error: Error) => {
      toast.error(`Failed to delete vendor: ${error.message}`);
    },
  });
}

Step 6: Create Page Components

6.1 Create Vendors List Page

Create app/vendors/page.tsx:

import { requireMemberSession } from '@/lib/auth/stytch/server';
import { getServerPermissions } from '@/lib/auth/server-permissions';
import { VendorList } from './components/vendor-list';

export const metadata = {
  title: 'Vendors',
  description: 'Manage your vendors',
};

export default async function VendorsPage() {
  // Require authentication
  const session = await requireMemberSession();

  // Check permissions
  const permissions = await getServerPermissions(session);
  if (!permissions.canViewVendors) {
    return <div className="p-6">Access Denied</div>;
  }

  return (
    <div className="container mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Vendors</h1>
      <VendorList />
    </div>
  );
}

6.2 Create Vendor List Component

Create app/vendors/components/vendor-list.tsx:

'use client';
import { useVendorsQuery } from '@/lib/hooks/queries/use-vendors-query';
import { usePermissions } from '@/lib/hooks/use-permissions';
import { useDeleteVendor } from '@/lib/hooks/mutations/use-vendor-mutations';
import { Button } from '@/components/ui/button';
import Link from 'next/link';

export function VendorList() {
  const { data: vendors, isLoading } = useVendorsQuery();
  const { hasPermission } = usePermissions();
  const { mutate: deleteVendor } = useDeleteVendor();

  if (isLoading) {
    return <div>Loading vendors...</div>;
  }

  return (
    <div>
      {hasPermission('vendor:create') && (
        <Link href="/vendors/new">
          <Button>Create Vendor</Button>
        </Link>
      )}

      <table className="w-full mt-4">
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
            <th>Status</th>
            <th>Actions</th>
          </tr>
        </thead>
        <tbody>
          {vendors?.map(vendor => (
            <tr key={vendor.id}>
              <td>{vendor.name}</td>
              <td>{vendor.email}</td>
              <td>{vendor.status}</td>
              <td>
                <Link href={`/vendors/${vendor.id}`}>View</Link>
                {hasPermission('vendor:edit') && (
                  <Link href={`/vendors/${vendor.id}/edit`}>Edit</Link>
                )}
                {hasPermission('vendor:delete') && (
                  <button onClick={() => deleteVendor(vendor.id)}>
                    Delete
                  </button>
                )}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

6.3 Create Vendor Detail Page

Create app/vendors/[id]/page.tsx:

import { requireMemberSession } from '@/lib/auth/stytch/server';
import { getServerPermissions } from '@/lib/auth/server-permissions';
import { vendorRepository } from '@/lib/api/api/repositories/vendor-repository';
import { VendorDetail } from '../components/vendor-detail';

export default async function VendorDetailPage({ params }: { params: { id: string } }) {
  const session = await requireMemberSession();
  const permissions = await getServerPermissions(session);

  if (!permissions.canViewVendors) {
    return <div>Access Denied</div>;
  }

  const vendor = await vendorRepository.get(params.id, session.session_jwt);

  return <VendorDetail vendor={vendor} />;
}

6.4 Create Vendor Form Page

Create app/vendors/new/page.tsx:

import { requireMemberSession } from '@/lib/auth/stytch/server';
import { getServerPermissions } => '@/lib/auth/server-permissions';
import { VendorForm } from '../components/vendor-form';

export default async function NewVendorPage() {
  const session = await requireMemberSession();
  const permissions = await getServerPermissions(session);

  if (!permissions.canCreateVendors) {
    return <div>Access Denied</div>;
  }

  return (
    <div className="container mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Create Vendor</h1>
      <VendorForm />
    </div>
  );
}

6.5 Create Vendor Form Component

Create app/vendors/components/vendor-form.tsx:

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useCreateVendor } from '@/lib/hooks/mutations/use-vendor-mutations';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

export function VendorForm() {
  const router = useRouter();
  const { mutate: createVendor, isPending } = useCreateVendor();
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    phone: '',
    address: '',
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    createVendor(formData, {
      onSuccess: () => {
        router.push('/vendors');
      },
    });
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <Input
        label="Name"
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
        required
      />
      <Input
        label="Email"
        type="email"
        value={formData.email}
        onChange={(e) => setFormData({ ...formData, email: e.target.value })}
        required
      />
      <Input
        label="Phone"
        value={formData.phone}
        onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
      />
      <Input
        label="Address"
        value={formData.address}
        onChange={(e) => setFormData({ ...formData, address: e.target.value })}
      />
      <Button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Vendor'}
      </Button>
    </form>
  );
}

Step 7: Add to Navigation

Edit your dashboard layout to add vendor link:

// app/dashboard/layout.tsx (or your sidebar component)

<nav>
  {hasPermission('vendor:view') && (
    <Link href="/vendors">
      Vendors
    </Link>
  )}
</nav>

Step 8: Test the Feature

8.1 Test Authentication

  1. Visit /vendors without logging in
  2. Should redirect to /auth

8.2 Test Permissions

  1. Log in as user without vendor:view permission

  2. Visit /vendors

  3. Should see "Access Denied"

  4. Log in as user with vendor:view permission

  5. Should see vendor list

8.3 Test CRUD Operations

Create:

  1. Click "Create Vendor"
  2. Fill form
  3. Click submit
  4. Should redirect to list
  5. Should see new vendor

Read:

  1. Click vendor name
  2. Should see vendor details

Update:

  1. Click "Edit"
  2. Change name
  3. Submit
  4. Should update in list

Delete:

  1. Click "Delete"
  2. Confirm
  3. Should remove from list

File Creation Summary

graph TD
    A[lib/auth/permissions.ts] --> B[Add VENDOR_* permissions]
    C[lib/auth/server-permissions.ts] --> D[Add canViewVendors, etc.]
    E[lib/api/api/repositories/vendor-repository.ts] --> F[NEW: Repository]
    G[lib/hooks/queries/use-vendors-query.ts] --> H[NEW: Query hooks]
    I[lib/hooks/mutations/use-vendor-mutations.ts] --> J[NEW: Mutation hooks]
    K[app/vendors/page.tsx] --> L[NEW: List page]
    M[app/vendors/new/page.tsx] --> N[NEW: Create page]
    O[app/vendors/[id]/page.tsx] --> P[NEW: Detail page]
    Q[app/vendors/components/] --> R[NEW: Components]

Checklist

  • Define permissions
  • Update server permissions
  • Create repository
  • Create query hooks
  • Create mutation hooks
  • Create list page
  • Create detail page
  • Create form page
  • Create components
  • Add to navigation
  • Test authentication
  • Test permissions
  • Test CRUD operations

Best Practices Applied

  1. Permission-first - Check permissions everywhere
  2. Repository pattern - Centralized API access
  3. Hook-based - Use React Query for data fetching
  4. Server + Client - Server components for data, client for interactivity
  5. Type-safe - TypeScript interfaces for all data
  6. Error handling - Toast notifications on errors
  7. Loading states - Show loading indicators
  8. Cache invalidation - Refresh data after mutations

Common Issues

Issue: "Access Denied" for all users

Solution: Check that permissions are added in Stytch dashboard for user roles.

Issue: Data not refreshing after create

Solution: Ensure invalidateQueries is called in mutation hooks.

Issue: TypeScript errors on repository

Solution: Define proper interfaces for all data types.

Next Steps

Now you know how to:

  • Add permissions
  • Create repositories
  • Build query/mutation hooks
  • Create protected pages
  • Add to navigation

Apply this pattern to add any feature to your app!


👉 Back to: Documentation Home