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
- Define Database Schema: Add tables (e.g.,
vendors) in your backend. - Create API Endpoints: specific generic REST endpoints (
GET /vendors,POST /vendors). - Define Permissions: Add generic permissions in
src/pkg/auth/rbac.go(e.g.,vendor:view).
2. Frontend Data Layer
- Add Permissions: Update
lib/auth/permissions.tsto match backend. - Create Repository: Add
lib/api/api/repositories/vendor-repository.ts. - Create Hooks: Add
useVendorsQueryanduseCreateVendorMutation.
3. UI Layer
- Create Page: Add
app/vendors/page.tsx(Server Component).- Checks permissions & fetches initial data.
- 👉 See Creating Pages Guide
- Create Components: Build
VendorList.tsxandVendorForm.tsx(Client Components).- Uses hooks for interactivity.
- 👉 See Creating Components Guide
- 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:viewpermission can see vendors - Users with
vendor:createpermission can add vendors - Users with
vendor:editpermission can modify vendors - Users with
vendor:deletepermission 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 vendorsGET /vendors/:id- Get vendor by IDPOST /vendors- Create vendorPUT /vendors/:id- Update vendorDELETE /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
- Visit
/vendorswithout logging in - Should redirect to
/auth
8.2 Test Permissions
-
Log in as user without
vendor:viewpermission -
Visit
/vendors -
Should see "Access Denied"
-
Log in as user with
vendor:viewpermission -
Should see vendor list
8.3 Test CRUD Operations
Create:
- Click "Create Vendor"
- Fill form
- Click submit
- Should redirect to list
- Should see new vendor
Read:
- Click vendor name
- Should see vendor details
Update:
- Click "Edit"
- Change name
- Submit
- Should update in list
Delete:
- Click "Delete"
- Confirm
- 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
- Permission-first - Check permissions everywhere
- Repository pattern - Centralized API access
- Hook-based - Use React Query for data fetching
- Server + Client - Server components for data, client for interactivity
- Type-safe - TypeScript interfaces for all data
- Error handling - Toast notifications on errors
- Loading states - Show loading indicators
- 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