Customization Guide
Customize and extend ShopySeed — branding, UI changes, business features, database extensions, and more.
This guide covers how to customize and extend ShopySeed to build your specific SaaS application. From branding and UI changes to adding new features and business logic.
Table of Contents
- Branding & Identity
- UI Customization
- Adding Business Features
- Database Schema Extensions
- API Customization
- Frontend Customization
- Authentication Providers
- Billing Plans & Features
- Email Templates
- File Uploads & Storage
Branding & Identity
Application Name & Metadata
1. Update Package Information
// package.json
{
"name": "your-saas-name",
"description": "Your SaaS description",
"version": "1.0.0",
"author": "Your Company",
"license": "PROPRIETARY"
}
// apps/web/package.json
{
"name": "your-saas-web",
"description": "Your SaaS web application"
}
// apps/api/package.json
{
"name": "your-saas-api",
"description": "Your SaaS API server"
}2. Update Application Metadata
// apps/web/src/app/layout.tsx
export const metadata: Metadata = {
title: 'Your SaaS Name',
description: 'Your SaaS description for better productivity',
keywords: ['saas', 'productivity', 'your-keywords'],
authors: [{ name: 'Your Company' }],
openGraph: {
title: 'Your SaaS Name',
description: 'Your SaaS description',
url: 'https://yourdomain.com',
siteName: 'Your SaaS Name',
images: [
{
url: 'https://yourdomain.com/og-image.jpg',
width: 1200,
height: 630,
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Your SaaS Name',
description: 'Your SaaS description',
creator: '@yourtwitterhandle',
images: ['https://yourdomain.com/twitter-image.jpg'],
},
};3. Update API Documentation
// apps/api/src/main.ts
const config = new DocumentBuilder()
.setTitle('Your SaaS API')
.setDescription('API documentation for Your SaaS')
.setVersion('1.0')
.addBearerAuth()
.setContact('Your Company', 'https://yourdomain.com', 'api@yourdomain.com')
.setLicense('Proprietary', 'https://yourdomain.com/license')
.build();Logo & Visual Identity
1. Replace Logo Assets
# Replace these files with your brand assets
apps/web/public/logo.svg # Main logo
apps/web/public/logo-dark.svg # Dark mode logo
apps/web/public/favicon.ico # Favicon
apps/web/public/apple-touch-icon.png # iOS icon
apps/web/public/og-image.jpg # OpenGraph image2. Update Logo Component
// apps/web/src/components/ui/logo.tsx
import Image from 'next/image';
import { useTheme } from 'next-themes';
export function Logo({ className = '', ...props }) {
const { theme } = useTheme();
return (
<Image
src={theme === 'dark' ? '/logo-dark.svg' : '/logo.svg'}
alt="Your SaaS Name"
width={120}
height={40}
className={className}
{...props}
/>
);
}3. Update Brand Colors
/* apps/web/src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Update these with your brand colors */
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%; /* Your primary brand color */
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--accent: 210 40% 96%;
/* ... rest of color variables */
}
.dark {
/* Dark mode colors */
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... */
}
}
/* Custom brand utilities */
.text-brand {
@apply text-primary;
}
.bg-brand {
@apply bg-primary;
}
.border-brand {
@apply border-primary;
}UI Customization
Theme Customization
1. Tailwind Configuration
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// Your brand color palette
brand: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6', // Primary brand color
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
// Custom semantic colors
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
},
fontFamily: {
// Custom fonts
sans: ['Inter', 'system-ui', 'sans-serif'],
heading: ['Poppins', 'system-ui', 'sans-serif'],
},
animation: {
// Custom animations
'slide-in': 'slideIn 0.3s ease-in-out',
'fade-in': 'fadeIn 0.5s ease-in-out',
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
};2. Component Theme Customization
// apps/web/src/lib/theme.ts
export const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
{
variants: {
variant: {
default: "bg-brand-500 text-white hover:bg-brand-600",
destructive: "bg-red-500 text-white hover:bg-red-600",
outline: "border border-brand-300 bg-transparent hover:bg-brand-50",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
ghost: "hover:bg-brand-50 hover:text-brand-900",
link: "text-brand-500 underline-offset-4 hover:underline",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);Layout Customization
1. Navigation Structure
// apps/web/src/components/layout/navigation.tsx
const navigationItems = [
{
title: 'Dashboard',
href: '/dashboard',
icon: Home,
description: 'Overview of your account'
},
{
title: 'Your Feature', // Add your custom features
href: '/dashboard/your-feature',
icon: YourIcon,
description: 'Description of your feature'
},
{
title: 'Analytics', // Example custom feature
href: '/dashboard/analytics',
icon: BarChart3,
description: 'View your analytics'
},
// ... existing items
];2. Dashboard Layout
// apps/web/src/app/dashboard/layout.tsx
import { Navigation } from '@/components/layout/navigation';
import { Header } from '@/components/layout/header';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex">
<aside className="w-64 min-h-screen bg-white shadow-sm">
<Navigation />
</aside>
<main className="flex-1 p-6">
{children}
</main>
</div>
</div>
);
}Adding Business Features
Example: Adding a "Projects" Feature
1. Database Schema
// apps/api/prisma/schema.prisma
model Project {
id String @id @default(uuid())
name String
description String?
status ProjectStatus @default(ACTIVE)
ownerId String
teamId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
team Team? @relation(fields: [teamId], references: [id])
tasks Task[]
@@map("projects")
// This table will be created in each tenant schema
}
enum ProjectStatus {
ACTIVE
COMPLETED
ARCHIVED
ON_HOLD
}
model Task {
id String @id @default(uuid())
title String
description String?
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
projectId String
assigneeId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
assignee User? @relation(fields: [assigneeId], references: [id])
@@map("tasks")
}
enum TaskStatus {
TODO
IN_PROGRESS
REVIEW
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}2. Backend API Module
// apps/api/src/projects/projects.controller.ts
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { OrganizationGuard } from '../common/guards/organization.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { CurrentOrganization } from '../common/decorators/current-organization.decorator';
import { ProjectsService } from './projects.service';
import { CreateProjectDto, UpdateProjectDto, ProjectListDto } from './dto';
@ApiTags('projects')
@Controller('projects')
@UseGuards(JwtAuthGuard, OrganizationGuard)
@ApiBearerAuth()
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@Post()
@ApiOperation({ summary: 'Create a new project' })
async create(
@CurrentUser('id') userId: string,
@CurrentOrganization() organizationId: string,
@Body() createProjectDto: CreateProjectDto,
) {
return this.projectsService.create(userId, organizationId, createProjectDto);
}
@Get()
@ApiOperation({ summary: 'Get all projects for organization' })
async findAll(
@CurrentOrganization() organizationId: string,
@Query() query: ProjectListDto,
) {
return this.projectsService.findAll(organizationId, query);
}
@Get(':id')
@ApiOperation({ summary: 'Get project by ID' })
async findOne(
@Param('id') id: string,
@CurrentOrganization() organizationId: string,
) {
return this.projectsService.findOne(id, organizationId);
}
@Patch(':id')
@ApiOperation({ summary: 'Update project' })
async update(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@CurrentOrganization() organizationId: string,
@Body() updateProjectDto: UpdateProjectDto,
) {
return this.projectsService.update(id, userId, organizationId, updateProjectDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete project' })
async remove(
@Param('id') id: string,
@CurrentUser('id') userId: string,
@CurrentOrganization() organizationId: string,
) {
return this.projectsService.remove(id, userId, organizationId);
}
}3. Frontend Pages
// apps/web/src/app/dashboard/projects/page.tsx
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CreateProjectDialog } from '@/components/projects/create-project-dialog';
import { ProjectCard } from '@/components/projects/project-card';
import { useProjects } from '@/hooks/use-projects';
import { Plus, Folder, Clock, CheckCircle } from 'lucide-react';
export default function ProjectsPage() {
const { projects, loading, createProject, deleteProject } = useProjects();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const stats = {
total: projects.length,
active: projects.filter(p => p.status === 'ACTIVE').length,
completed: projects.filter(p => p.status === 'COMPLETED').length,
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage and track your projects
</p>
</div>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
New Project
</Button>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<Folder className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.active}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completed}</div>
</CardContent>
</Card>
</div>
{/* Projects Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<ProjectCard
key={project.id}
project={project}
onDelete={() => deleteProject(project.id)}
/>
))}
</div>
{/* Create Dialog */}
<CreateProjectDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
onSubmit={createProject}
/>
</div>
);
}Database Schema Extensions
Adding Tenant-Scoped Tables
When adding new features that should be isolated per organization:
1. Multi-Tenant Table Design
// Any table that should be tenant-specific
model YourCustomTable {
id String @id @default(uuid())
// Always include organizationId for tenant isolation
organizationId String
name String
data Json? // Flexible data storage
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relation to organization (in public schema)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@map("your_custom_table")
// This will be created in tenant schemas
}2. Migration Strategy
# Create migration with descriptive name
cd apps/api
npx prisma migrate dev --create-only --name add_your_feature
# Review the generated SQL before applying
cat prisma/migrations/*_add_your_feature/migration.sql
# Apply if safe
npx prisma migrate dev3. Tenant Schema Management
// apps/api/src/common/services/tenant.service.ts
@Injectable()
export class TenantService {
constructor(private prisma: PrismaService) {}
async createTenantSchema(organizationId: string): Promise<void> {
const schemaName = `tenant_${organizationId.replace('-', '')}`;
// Create schema
await this.prisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${Prisma.raw(schemaName)}`;
// Create tenant-specific tables
await this.prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS ${Prisma.raw(schemaName)}.your_custom_table (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
data JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
)
`;
// Add indexes
await this.prisma.$executeRaw`
CREATE INDEX IF NOT EXISTS idx_your_custom_table_org_id
ON ${Prisma.raw(schemaName)}.your_custom_table(organization_id)
`;
}
}Adding System-Wide Tables
For features that span across all tenants (like admin features):
// These stay in the public schema
model SystemWideFeature {
id String @id @default(uuid())
name String
description String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("system_wide_features")
}API Customization
Adding Custom Endpoints
1. Custom Business Logic Endpoint
// apps/api/src/analytics/analytics.controller.ts
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { OrganizationGuard } from '../common/guards/organization.guard';
import { CurrentOrganization } from '../common/decorators/current-organization.decorator';
import { AnalyticsService } from './analytics.service';
@ApiTags('analytics')
@Controller('analytics')
@UseGuards(JwtAuthGuard, OrganizationGuard)
@ApiBearerAuth()
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Get('dashboard')
@ApiOperation({ summary: 'Get dashboard analytics' })
async getDashboardAnalytics(
@CurrentOrganization() organizationId: string,
@Query('period') period: string = '30d',
) {
return this.analyticsService.getDashboardAnalytics(organizationId, period);
}
@Get('revenue')
@ApiOperation({ summary: 'Get revenue analytics' })
async getRevenueAnalytics(
@CurrentOrganization() organizationId: string,
@Query('from') from?: string,
@Query('to') to?: string,
) {
return this.analyticsService.getRevenueAnalytics(organizationId, { from, to });
}
}2. Custom Middleware
// apps/api/src/common/middleware/audit.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AuditMiddleware implements NestMiddleware {
constructor(private prisma: PrismaService) {}
async use(req: Request, res: Response, next: NextFunction) {
// Log API calls for audit purposes
if (req.method !== 'GET' && req.user) {
await this.prisma.auditLog.create({
data: {
userId: req.user.id,
organizationId: req.headers['x-organization-id'] as string,
action: `${req.method} ${req.path}`,
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
requestBody: req.method === 'POST' ? req.body : null,
},
});
}
next();
}
}Custom Validation & DTOs
// apps/api/src/common/validators/custom.validator.ts
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
export function IsSlugValid(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isSlugValid',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
return typeof value === 'string' && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
},
defaultMessage(args: ValidationArguments) {
return 'Slug must contain only lowercase letters, numbers, and hyphens';
},
},
});
};
}
// Usage in DTO
export class CreateProjectDto {
@IsNotEmpty()
@IsString()
@Length(1, 100)
name: string;
@IsOptional()
@IsString()
@IsSlugValid()
slug?: string;
@IsOptional()
@IsString()
@Length(0, 500)
description?: string;
}Frontend Customization
Adding Custom Pages
1. New Feature Page
// apps/web/src/app/dashboard/analytics/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RevenueChart } from '@/components/analytics/revenue-chart';
import { UserGrowthChart } from '@/components/analytics/user-growth-chart';
import { TopProductsTable } from '@/components/analytics/top-products-table';
import { useAnalytics } from '@/hooks/use-analytics';
export default function AnalyticsPage() {
const [period, setPeriod] = useState('30d');
const { dashboardData, revenueData, loading } = useAnalytics(period);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Analytics</h1>
<p className="text-muted-foreground">
Track your business performance and growth
</p>
</div>
<Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="90d">Last 90 days</SelectItem>
<SelectItem value="1y">Last year</SelectItem>
</SelectContent>
</Select>
</div>
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">${dashboardData?.revenue?.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">+12% from last month</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Active Users</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{dashboardData?.activeUsers?.toLocaleString()}</div>
<p className="text-xs text-muted-foreground">+5% from last month</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Conversion Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{dashboardData?.conversionRate}%</div>
<p className="text-xs text-muted-foreground">+0.5% from last month</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Churn Rate</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{dashboardData?.churnRate}%</div>
<p className="text-xs text-muted-foreground">-0.8% from last month</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<Tabs defaultValue="revenue" className="space-y-4">
<TabsList>
<TabsTrigger value="revenue">Revenue</TabsTrigger>
<TabsTrigger value="users">User Growth</TabsTrigger>
<TabsTrigger value="products">Top Products</TabsTrigger>
</TabsList>
<TabsContent value="revenue">
<Card>
<CardHeader>
<CardTitle>Revenue Overview</CardTitle>
</CardHeader>
<CardContent>
<RevenueChart data={revenueData} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="users">
<Card>
<CardHeader>
<CardTitle>User Growth</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart period={period} />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="products">
<TopProductsTable period={period} />
</TabsContent>
</Tabs>
</div>
);
}2. Custom Hooks
// apps/web/src/hooks/use-analytics.ts
import { useState, useEffect } from 'react';
import { useOrganization } from '@/contexts/organization-context';
import { analyticsApi } from '@/lib/api/analytics';
interface AnalyticsData {
revenue: number;
activeUsers: number;
conversionRate: number;
churnRate: number;
}
interface RevenueData {
date: string;
revenue: number;
subscriptions: number;
}
export function useAnalytics(period: string) {
const { currentOrganization } = useOrganization();
const [dashboardData, setDashboardData] = useState<AnalyticsData | null>(null);
const [revenueData, setRevenueData] = useState<RevenueData[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchAnalytics() {
if (!currentOrganization?.id) return;
try {
setLoading(true);
const [dashboard, revenue] = await Promise.all([
analyticsApi.getDashboard(period),
analyticsApi.getRevenue(period),
]);
setDashboardData(dashboard);
setRevenueData(revenue);
} catch (error) {
console.error('Failed to fetch analytics:', error);
} finally {
setLoading(false);
}
}
fetchAnalytics();
}, [currentOrganization?.id, period]);
return {
dashboardData,
revenueData,
loading,
};
}Custom Components
// apps/web/src/components/ui/data-table.tsx
'use client';
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
SortingState,
ColumnFiltersState,
} from '@tanstack/react-table';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
searchPlaceholder?: string;
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchPlaceholder = 'Filter...',
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
state: {
sorting,
columnFilters,
},
});
return (
<div className="space-y-4">
{searchKey && (
<div className="flex items-center py-4">
<Input
placeholder={searchPlaceholder}
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''}
onChange={(event) =>
table.getColumn(searchKey)?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2">
<div className="flex-1 text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
);
}Authentication Providers
Adding OAuth Providers
1. Backend OAuth Configuration
// apps/api/src/auth/strategies/twitter.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-twitter';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth.service';
@Injectable()
export class TwitterStrategy extends PassportStrategy(Strategy, 'twitter') {
constructor(
private configService: ConfigService,
private authService: AuthService,
) {
super({
consumerKey: configService.get('TWITTER_CONSUMER_KEY'),
consumerSecret: configService.get('TWITTER_CONSUMER_SECRET'),
callbackURL: configService.get('TWITTER_CALLBACK_URL'),
includeEmail: true,
});
}
async validate(
accessToken: string,
refreshToken: string,
profile: any,
): Promise<any> {
return this.authService.validateOAuthUser({
provider: 'twitter',
providerId: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
avatarUrl: profile.photos?.[0]?.value,
});
}
}2. OAuth Routes
// apps/api/src/auth/auth.controller.ts (add to existing)
@Get('oauth/twitter')
@UseGuards(AuthGuard('twitter'))
@ApiOperation({ summary: 'Initiate Twitter OAuth' })
async twitterOAuth() {
// Initiates Twitter OAuth flow
}
@Get('oauth/twitter/callback')
@UseGuards(AuthGuard('twitter'))
@ApiOperation({ summary: 'Handle Twitter OAuth callback' })
async twitterOAuthCallback(@Req() req: any, @Res() res: any): Promise<void> {
const result = await this.authService.handleOAuthCallback(req.user);
// Redirect to frontend with tokens
const redirectUrl = `${this.configService.get('FRONTEND_URL')}/auth/callback?token=${result.accessToken}&refresh=${result.refreshToken}`;
res.redirect(redirectUrl);
}3. Frontend OAuth Button
// apps/web/src/components/auth/oauth-buttons.tsx
import { Button } from '@/components/ui/button';
import { Icons } from '@/components/icons';
interface OAuthButtonsProps {
disabled?: boolean;
}
export function OAuthButtons({ disabled }: OAuthButtonsProps) {
const handleOAuth = (provider: string) => {
window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/oauth/${provider}`;
};
return (
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
onClick={() => handleOAuth('google')}
disabled={disabled}
className="w-full"
>
<Icons.google className="mr-2 h-4 w-4" />
Google
</Button>
<Button
variant="outline"
onClick={() => handleOAuth('github')}
disabled={disabled}
className="w-full"
>
<Icons.github className="mr-2 h-4 w-4" />
GitHub
</Button>
<Button
variant="outline"
onClick={() => handleOAuth('twitter')}
disabled={disabled}
className="w-full"
>
<Icons.twitter className="mr-2 h-4 w-4" />
Twitter
</Button>
<Button
variant="outline"
onClick={() => handleOAuth('linkedin')}
disabled={disabled}
className="w-full"
>
<Icons.linkedin className="mr-2 h-4 w-4" />
LinkedIn
</Button>
</div>
);
}Billing Plans & Features
Customizing Subscription Plans
1. Update Plan Configuration
// apps/api/src/billing/config/plans.ts
export const PLANS = {
free: {
name: 'Free',
stripePriceId: null,
price: null,
limits: {
users: 2,
projects: 3,
storage: 1024 * 1024 * 1024, // 1GB in bytes
apiCalls: 1000,
features: ['basic_support'],
},
features: [
'Up to 2 team members',
'Up to 3 projects',
'1GB storage',
'1,000 API calls/month',
'Basic support',
],
},
starter: {
name: 'Starter',
stripePriceId: 'price_1234567890',
price: 29,
limits: {
users: 10,
projects: 25,
storage: 10 * 1024 * 1024 * 1024, // 10GB
apiCalls: 10000,
features: ['priority_support', 'advanced_analytics'],
},
features: [
'Up to 10 team members',
'Up to 25 projects',
'10GB storage',
'10,000 API calls/month',
'Priority support',
'Advanced analytics',
],
},
pro: {
name: 'Pro',
stripePriceId: 'price_0987654321',
price: 99,
limits: {
users: 50,
projects: -1, // Unlimited
storage: 100 * 1024 * 1024 * 1024, // 100GB
apiCalls: 100000,
features: ['priority_support', 'advanced_analytics', 'custom_integrations', 'api_access'],
},
features: [
'Up to 50 team members',
'Unlimited projects',
'100GB storage',
'100,000 API calls/month',
'Priority support',
'Advanced analytics',
'Custom integrations',
'API access',
],
},
enterprise: {
name: 'Enterprise',
stripePriceId: 'price_enterprise',
price: null, // Contact sales
limits: {
users: -1, // Unlimited
projects: -1,
storage: -1,
apiCalls: -1,
features: ['all'],
},
features: [
'Unlimited everything',
'Dedicated support',
'Custom features',
'SLA guarantee',
'On-premise option',
],
},
};2. Feature Gates
// apps/api/src/common/guards/feature.guard.ts
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { BillingService } from '../../billing/billing.service';
@Injectable()
export class FeatureGuard implements CanActivate {
constructor(
private reflector: Reflector,
private billingService: BillingService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredFeature = this.reflector.get<string>('feature', context.getHandler());
if (!requiredFeature) return true;
const request = context.switchToHttp().getRequest();
const organizationId = request.headers['x-organization-id'];
const hasFeature = await this.billingService.hasFeature(organizationId, requiredFeature);
if (!hasFeature) {
throw new ForbiddenException(`This feature requires ${requiredFeature} plan or higher`);
}
return true;
}
}
// Usage decorator
export const RequiresFeature = (feature: string) => SetMetadata('feature', feature);3. Usage in Controllers
// apps/api/src/analytics/analytics.controller.ts
@Get('advanced')
@UseGuards(JwtAuthGuard, OrganizationGuard, FeatureGuard)
@RequiresFeature('advanced_analytics')
@ApiOperation({ summary: 'Get advanced analytics (Pro+ only)' })
async getAdvancedAnalytics(@CurrentOrganization() organizationId: string) {
return this.analyticsService.getAdvancedAnalytics(organizationId);
}Email Templates
Custom Email Templates
1. Template Structure
// apps/api/src/email/templates/welcome.template.ts
export interface WelcomeEmailData {
userName: string;
verificationUrl: string;
supportUrl: string;
}
export const welcomeTemplate = (data: WelcomeEmailData) => ({
subject: `Welcome to ${process.env.APP_NAME}! 🎉`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Welcome</title>
<style>
/* Your email styles */
.container { max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif; }
.header { background: #3b82f6; color: white; padding: 20px; text-align: center; }
.content { padding: 20px; }
.button { display: inline-block; background: #3b82f6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Welcome to ${process.env.APP_NAME}!</h1>
</div>
<div class="content">
<p>Hi ${data.userName},</p>
<p>Welcome to ${process.env.APP_NAME}! We're excited to have you on board.</p>
<p>To get started, please verify your email address:</p>
<p>
<a href="${data.verificationUrl}" class="button">Verify Email</a>
</p>
<p>If you have any questions, feel free to reach out to our support team.</p>
<p>Best regards,<br>The ${process.env.APP_NAME} Team</p>
</div>
</div>
</body>
</html>
`,
text: `
Welcome to ${process.env.APP_NAME}!
Hi ${data.userName},
Welcome to ${process.env.APP_NAME}! We're excited to have you on board.
To get started, please verify your email address by visiting: ${data.verificationUrl}
If you have any questions, feel free to reach out to our support team.
Best regards,
The ${process.env.APP_NAME} Team
`,
});2. Email Service
// apps/api/src/email/email.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
import { welcomeTemplate, WelcomeEmailData } from './templates/welcome.template';
@Injectable()
export class EmailService {
private transporter: nodemailer.Transporter;
constructor(private configService: ConfigService) {
this.transporter = nodemailer.createTransporter({
host: this.configService.get('EMAIL_HOST'),
port: this.configService.get('EMAIL_PORT'),
secure: false,
auth: {
user: this.configService.get('EMAIL_USER'),
pass: this.configService.get('EMAIL_PASSWORD'),
},
});
}
async sendWelcomeEmail(to: string, data: WelcomeEmailData): Promise<void> {
const template = welcomeTemplate(data);
await this.transporter.sendMail({
from: this.configService.get('EMAIL_FROM'),
to,
subject: template.subject,
html: template.html,
text: template.text,
});
}
async sendCustomEmail(
to: string,
subject: string,
html: string,
text?: string,
): Promise<void> {
await this.transporter.sendMail({
from: this.configService.get('EMAIL_FROM'),
to,
subject,
html,
text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text version
});
}
}File Uploads & Storage
Adding File Upload Capability
1. Backend Upload Handler
// apps/api/src/uploads/uploads.controller.ts
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiConsumes, ApiBody, ApiBearerAuth } from '@nestjs/swagger';
import { Express } from 'express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@ApiTags('uploads')
@Controller('uploads')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class UploadsController {
@Post('avatar')
@UseInterceptors(FileInterceptor('file', {
storage: diskStorage({
destination: './uploads/avatars',
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `avatar-${uniqueSuffix}${extname(file.originalname)}`);
},
}),
fileFilter: (req, file, cb) => {
if (!file.originalname.match(/\.(jpg|jpeg|png|gif)$/)) {
return cb(new BadRequestException('Only image files are allowed!'), false);
}
cb(null, true);
},
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
}))
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
},
},
})
async uploadAvatar(
@UploadedFile() file: Express.Multer.File,
@CurrentUser('id') userId: string,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
const fileUrl = `/uploads/avatars/${file.filename}`;
// Update user avatar in database
await this.usersService.updateAvatar(userId, fileUrl);
return {
filename: file.filename,
url: fileUrl,
size: file.size,
};
}
}2. S3 Integration (Production)
// apps/api/src/uploads/s3.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as AWS from 'aws-sdk';
@Injectable()
export class S3Service {
private s3: AWS.S3;
constructor(private configService: ConfigService) {
this.s3 = new AWS.S3({
accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'),
secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'),
region: this.configService.get('AWS_REGION'),
});
}
async uploadFile(file: Express.Multer.File, path: string): Promise<string> {
const uploadParams = {
Bucket: this.configService.get('AWS_S3_BUCKET'),
Key: `${path}/${Date.now()}-${file.originalname}`,
Body: file.buffer,
ContentType: file.mimetype,
ACL: 'public-read',
};
const result = await this.s3.upload(uploadParams).promise();
return result.Location;
}
async deleteFile(key: string): Promise<void> {
const deleteParams = {
Bucket: this.configService.get('AWS_S3_BUCKET'),
Key: key,
};
await this.s3.deleteObject(deleteParams).promise();
}
}3. Frontend Upload Component
// apps/web/src/components/ui/file-upload.tsx
'use client';
import { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Upload, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface FileUploadProps {
accept?: string;
maxSize?: number; // in MB
onUpload: (file: File) => Promise<{ url: string }>;
className?: string;
}
export function FileUpload({
accept = 'image/*',
maxSize = 5,
onUpload,
className
}: FileUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file size
if (file.size > maxSize * 1024 * 1024) {
alert(`File size must be less than ${maxSize}MB`);
return;
}
// Show preview for images
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => setPreviewUrl(e.target?.result as string);
reader.readAsDataURL(file);
}
try {
setIsUploading(true);
setProgress(0);
// Simulate progress (in real app, use upload progress)
const interval = setInterval(() => {
setProgress((prev) => Math.min(prev + 10, 90));
}, 100);
const result = await onUpload(file);
clearInterval(interval);
setProgress(100);
setTimeout(() => {
setIsUploading(false);
setProgress(0);
}, 1000);
} catch (error) {
setIsUploading(false);
setProgress(0);
alert('Upload failed. Please try again.');
}
};
const clearPreview = () => {
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className={cn('space-y-4', className)}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileSelect}
className="hidden"
/>
{previewUrl ? (
<div className="relative inline-block">
<img
src={previewUrl}
alt="Preview"
className="w-32 h-32 object-cover rounded-lg border"
/>
<Button
variant="destructive"
size="sm"
className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0"
onClick={clearPreview}
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="h-32 w-32 flex-col space-y-2"
>
<Upload className="h-8 w-8" />
<span className="text-sm">Upload File</span>
</Button>
)}
{isUploading && (
<div className="space-y-2">
<Progress value={progress} className="w-full" />
<p className="text-sm text-muted-foreground">Uploading... {progress}%</p>
</div>
)}
</div>
);
}Best Practices
Code Organization
- Feature-based Structure: Organize code by business features, not technical layers
- Shared Types: Keep API types in
packages/sharedfor consistency - Custom Hooks: Extract complex logic into reusable hooks
- Component Composition: Build complex UIs from simple, reusable components
Database Design
- Tenant Isolation: Always include organization context in multi-tenant tables
- Migrations: Test migrations on a copy of production data
- Indexes: Add indexes for commonly queried fields
- Constraints: Use database constraints to enforce data integrity
API Design
- RESTful Conventions: Follow REST conventions for predictable APIs
- Error Handling: Return consistent error responses
- Validation: Validate all inputs server-side
- Documentation: Keep Swagger documentation up-to-date
Security
- Input Validation: Never trust user input
- Authorization: Check permissions at the API level
- Rate Limiting: Protect against abuse
- Secrets Management: Use environment variables for sensitive data
ShopySeed provides a solid foundation that you can build upon. Focus on your unique business logic while leveraging the robust infrastructure already in place. 🚀