Loading...
Loading...
Build production-grade multi-tenant SaaS applications with team workspaces, member invitation, authentication, and modern UI
npx skill4agent add blink-new/claude team-saas| Layer | Technology | Version |
|---|---|---|
| Framework | Next.js (App Router) | 16.x |
| React | React | 19.x |
| Runtime | Bun | Latest |
| Database | PostgreSQL (Railway) | - |
| ORM | Prisma | 7.x |
| Auth | NextAuth v5 (Auth.js) | 5.0.0-beta |
| UI | shadcn/ui (new-york) | Latest |
| Styling | Tailwind CSS | v4 |
| Theming | next-themes | Latest |
| Icons | Lucide React | Latest |
| State | TanStack React Query | 5.x |
| Validation | Zod | 4.x |
| Resend | 6.x | |
| Storage | Railway S3-compatible | - |
| Jobs | pg-boss | 12.x |
| Hosting | Railway | - |
┌─────────────────────────────────────────────────────────────────────┐
│ Next.js Application │
├─────────────────────────────────────────────────────────────────────┤
│ Landing Page │ Auth Pages │ Dashboard (Protected) │
│ / │ /login │ /teams/[teamId]/* │
│ /pricing │ /register │ /dashboard │
│ │ /invite/[t] │ /settings │
├─────────────────────────────────────────────────────────────────────┤
│ API Routes │
│ /api/auth/* │ /api/teams/* │ /api/uploads/* │
│ /api/cron/* │ /api/webhooks/* │ /api/invitations/* │
├─────────────────────────────────────────────────────────────────────┤
│ proxy.ts (Security Headers) │ layout.tsx (Auth Protection) │
├─────────────────────────────────────────────────────────────────────┤
│ Services Layer │
│ Prisma (DB) │ Resend (Email) │ S3 (Storage) │ pg-boss (Jobs) │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Railway Infrastructure │
│ PostgreSQL │ S3 Bucket │ Redis (optional) │
└─────────────────────────────────────────────────────────────────────┘User ─┬─> TeamMember ─> Team ─┬─> Creator
│ ├─> Campaign
└─> TeamMember ─> Team ├─> Content
└─> ... (all team resources)src/app/
├── (auth)/ # Public auth pages (login, register)
│ └── layout.tsx # Client component, styling only
├── (dashboard)/ # Protected authenticated routes
│ ├── layout.tsx # Server component with auth() check + redirect
│ └── teams/[teamId]/ # Team-scoped pages
├── (marketing)/ # Public marketing pages
├── invite/[token]/ # Team invitation acceptance
└── api/ # API routes// src/app/(dashboard)/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardLayout({ children }) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return <DashboardShell session={session}>{children}</DashboardShell>;
}// Use api-helpers for clean, consistent patterns
import { requireTeamMember, parseJsonBody, withErrorHandler, ApiError } from "@/lib/api-helpers";
type RouteParams = {
params: Promise<{ teamId: string }>; // Next.js 15+ async params
};
export const POST = withErrorHandler(async (req: NextRequest, { params }: RouteParams) => {
const { teamId } = await params; // MUST await params
const { userId, role } = await requireTeamMember(teamId); // Throws 401/403
if (role !== "ADMIN") {
throw new ApiError("Admin access required", 403);
}
const data = await parseJsonBody(req, mySchema); // Validates with Zod
// ... business logic
return NextResponse.json(result);
});src/
├── app/
│ ├── (auth)/ # Auth pages (public)
│ │ ├── login/page.tsx
│ │ ├── register/page.tsx
│ │ └── layout.tsx # Client component, no auth
│ ├── (dashboard)/ # Protected pages
│ │ ├── layout.tsx # Server component, auth check
│ │ ├── dashboard-shell.tsx # Sidebar + nav
│ │ └── teams/[teamId]/
│ ├── invite/[token]/page.tsx # Invitation acceptance
│ ├── api/
│ │ ├── auth/[...nextauth]/route.ts
│ │ ├── teams/
│ │ │ ├── route.ts # List/create teams
│ │ │ └── [teamId]/
│ │ │ ├── route.ts # Team CRUD
│ │ │ ├── members/
│ │ │ └── invitations/
│ │ ├── invitations/
│ │ │ └── [token]/accept/route.ts
│ │ └── uploads/[...path]/route.ts # S3 proxy
│ ├── globals.css # Design system
│ ├── layout.tsx # Root layout with Providers
│ └── page.tsx # Landing page
├── components/
│ ├── ui/ # shadcn components
│ ├── teams/ # Team management
│ ├── invitations/ # Invitation UI
│ ├── shared/ # Reusable patterns
│ ├── providers.tsx # App providers
│ └── theme-toggle.tsx # Theme switcher
├── hooks/
│ ├── use-teams.ts # Team hooks
│ └── ...
├── lib/
│ ├── auth.ts # NextAuth config (Node.js runtime)
│ ├── auth.config.ts # Auth config (callbacks, pages)
│ ├── api-helpers.ts # withErrorHandler, requireTeamMember, etc.
│ ├── prisma.ts # Prisma client (lazy proxy)
│ ├── s3.ts # S3 utilities
│ ├── resend.ts # Email client
│ ├── query-client.ts # React Query
│ ├── utils.ts # cn(), getInitials(), etc.
│ └── jobs/ # pg-boss setup
├── proxy.ts # Security headers (NOT auth)
├── types/
│ └── next-auth.d.ts # Auth type extensions
└── generated/
└── prisma/ # Prisma client output| Asset | Description |
|---|---|
| NextAuth v5 with Credentials provider |
| Auth config (callbacks, custom pages) |
| withErrorHandler, requireTeamMember, ApiError |
| Lazy-loaded Prisma client with pg adapter |
| S3 utilities with presigned URLs |
| Resend email client + templates |
| React Query setup |
| pg-boss job queue |
| Utility functions |
| App providers |
| Light/dark/system toggle |
| Team dropdown |
| Linear-style design system |
| Production Docker build |
| Railway deployment |
| Next.js configuration |
| Security headers |
| Environment variables |
| shadcn configuration |
| Base team schema |
| Team API template |
| Invitations API template |
| React Query hooks |
| Type extensions |
bunx create-next-app@latest my-saas --typescript --tailwind --eslint --app --src-dir
cd my-saas# Core
bun add next-auth@beta @auth/prisma-adapter bcryptjs
bun add @prisma/client @prisma/adapter-pg pg
bun add -D prisma @types/bcryptjs
# UI
bun add lucide-react
bun add next-themes class-variance-authority clsx tailwind-merge
bun add @tanstack/react-query
# Validation
bun add zod
# Email
bun add resend
# Storage
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
# Jobs
bun add pg-boss
# shadcn
bunx shadcn@latest init
bunx shadcn@latest add button card dialog input label sonner tooltip dropdown-menu avatar badge form command popover.env.example# Required
DATABASE_URL="postgresql://..."
AUTH_SECRET="openssl rand -base64 32"
NEXTAUTH_URL="http://localhost:3000"
RESEND_API_KEY="re_..."
EMAIL_FROM="onboarding@resend.dev"
# S3 Storage (Railway)
AWS_ENDPOINT_URL="https://..."
AWS_ACCESS_KEY_ID="..."
AWS_SECRET_ACCESS_KEY="..."
AWS_S3_BUCKET_NAME="..."
AWS_DEFAULT_REGION="auto"# Initialize
bunx prisma init
# Copy the schema from assets/prisma/schema.prisma
# Ensure generator uses "prisma-client" (not "prisma-client-js")
# Ensure output is "../src/generated/prisma"
# Push to database
bunx prisma db push
bunx prisma generateassets/#8b5cf6#ffffffhsl(240 10% 10%)hsl(240 6% 90%)#0A0A0Bhsl(0 0% 95%)rgba(255,255,255,0.06)next-themesattribute="class"paramsPromisetype RouteParams = {
params: Promise<{ teamId: string }>;
};
export async function GET(req: NextRequest, { params }: RouteParams) {
const { teamId } = await params; // MUST await!
// ...
}import { NextRequest, NextResponse } from "next/server";
import { requireTeamMember, parseJsonBody, withErrorHandler, ApiError } from "@/lib/api-helpers";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const createSchema = z.object({
name: z.string().min(1),
});
type RouteParams = {
params: Promise<{ teamId: string }>;
};
export const POST = withErrorHandler(async (req: NextRequest, { params }: RouteParams) => {
const { teamId } = await params;
// Auth check - throws 401/403
await requireTeamMember(teamId);
// Parse and validate body - throws 400
const { name } = await parseJsonBody(req, createSchema);
// Business logic
const result = await prisma.someModel.create({
data: { name, teamId },
});
return NextResponse.json(result, { status: 201 });
});// Factory pattern for query keys
export const teamKeys = {
all: ["teams"] as const,
lists: () => [...teamKeys.all, "list"] as const,
details: () => [...teamKeys.all, "detail"] as const,
detail: (id: string) => [...teamKeys.details(), id] as const,
members: (id: string) => [...teamKeys.detail(id), "members"] as const,
};export function useCreateTeam() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createTeam,
onSuccess: (newTeam) => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
queryClient.setQueryData(teamKeys.detail(newTeam.id), newTeam);
},
});
}// Create job
await boss.send(QUEUES.SEND_EMAIL, payload, DEFAULT_JOB_OPTIONS);
// Worker handles job
boss.work(QUEUES.SEND_EMAIL, { batchSize: 1 }, async (job) => {
await handleSendEmail(job.data);
});// Schedule recurring jobs on worker startup
await boss.schedule(QUEUES.CLEANUP, "0 6 * * *", {}); // Daily at 6 AM UTCFROM node:22-alpine
RUN npm install -g bun
# ... build steps ...
CMD ["/app/start.sh"] # Runs both processes[build]
builder = "dockerfile"
[deploy]
healthcheckPath = "/"
healthcheckTimeout = 300
restartPolicyType = "on_failure"async rewrites() {
return [
{
source: "/uploads/:path*",
destination: "/api/uploads/:path*",
},
];
}