SaaS Project Templates: Template Library, Deep Copy Clone, Variable Substitution, and Preview
Build a project template system for SaaS. Covers template storage schema, deep copy cloning with PostgreSQL recursive CTE, variable substitution in template content, template preview without creating real records, and template gallery UI.
Project templates reduce time-to-value for new users β instead of starting from a blank project and wondering what to do, they pick a template that matches their use case and start with pre-populated tasks, milestones, and structure. Done well, templates also drive feature discovery: users see that tasks can have subtasks, due dates, and assignees because the template uses them.
Database Schema
-- Templates are owned by system (workspace_id NULL) or by a workspace
CREATE TABLE project_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, -- NULL = system template
name TEXT NOT NULL,
description TEXT,
category TEXT NOT NULL DEFAULT 'general'
CHECK (category IN ('general', 'engineering', 'marketing', 'design', 'ops')),
is_public BOOLEAN NOT NULL DEFAULT FALSE, -- System templates only
preview_data JSONB NOT NULL DEFAULT '{}', -- Snapshot for gallery preview
usage_count INTEGER NOT NULL DEFAULT 0, -- For sorting by popularity
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Template sections/phases
CREATE TABLE template_sections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
template_id UUID NOT NULL REFERENCES project_templates(id) ON DELETE CASCADE,
name TEXT NOT NULL,
position INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Template tasks within sections
CREATE TABLE template_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
section_id UUID NOT NULL REFERENCES template_sections(id) ON DELETE CASCADE,
parent_id UUID REFERENCES template_tasks(id), -- For subtasks
title TEXT NOT NULL, -- May contain {{variables}} like {{project_name}}
description TEXT,
position INTEGER NOT NULL,
-- Relative due date offset from project start
due_days_offset INTEGER, -- NULL = no due date; 7 = 7 days after project start
priority TEXT DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_template_sections_template ON template_sections(template_id, position);
CREATE INDEX idx_template_tasks_section ON template_tasks(section_id, position);
CREATE INDEX idx_template_tasks_parent ON template_tasks(parent_id);
CREATE INDEX idx_templates_public ON project_templates(is_public, category) WHERE is_public = TRUE;
Template Clone: Deep Copy with Variable Substitution
// lib/templates/clone.ts
import { prisma } from "@/lib/prisma";
export interface TemplateVariables {
projectName: string;
startDate?: string; // ISO date string; defaults to today
ownerId?: string;
}
export async function cloneTemplateToProject(
templateId: string,
workspaceId: string,
createdById: string,
variables: TemplateVariables,
): Promise<{ projectId: string }> {
const startDate = variables.startDate
? new Date(variables.startDate)
: new Date();
// Load template with all sections and tasks
const template = await prisma.projectTemplate.findFirstOrThrow({
where: {
id: templateId,
OR: [
{ workspaceId }, // Workspace's own template
{ isPublic: true }, // System template
],
},
include: {
sections: {
orderBy: { position: "asc" },
include: {
tasks: {
orderBy: [{ parentId: "asc" }, { position: "asc" }],
},
},
},
},
});
// Substitute variables in a string
function substitute(text: string | null): string | null {
if (!text) return text;
return text
.replace(/\{\{project_name\}\}/gi, variables.projectName)
.replace(/\{\{owner\}\}/gi, variables.ownerId ?? "Unassigned")
.replace(/\{\{start_date\}\}/gi, startDate.toLocaleDateString());
}
// Calculate absolute due date from offset
function dueDate(offsetDays: number | null): Date | null {
if (offsetDays === null || offsetDays === undefined) return null;
const d = new Date(startDate);
d.setDate(d.getDate() + offsetDays);
return d;
}
return prisma.$transaction(async (tx) => {
// 1. Create the project
const project = await tx.project.create({
data: {
workspaceId,
name: variables.projectName,
createdById,
templateId, // Track which template it came from
startDate,
},
select: { id: true },
});
// 2. Create sections
for (const section of template.sections) {
const newSection = await tx.projectSection.create({
data: {
projectId: project.id,
name: substitute(section.name) ?? section.name,
position: section.position,
},
select: { id: true },
});
// 3. Create tasks β handle parent/child after all sibling tasks created
// First pass: create all top-level tasks, build id mapping
const taskIdMap = new Map<string, string>(); // templateTaskId β newTaskId
const topLevelTasks = section.tasks.filter((t) => t.parentId === null);
for (const task of topLevelTasks) {
const newTask = await tx.task.create({
data: {
sectionId: newSection.id,
title: substitute(task.title) ?? task.title,
description: substitute(task.description),
position: task.position,
priority: task.priority,
dueDate: dueDate(task.dueDaysOffset),
assigneeId: variables.ownerId ?? null,
},
select: { id: true },
});
taskIdMap.set(task.id, newTask.id);
}
// Second pass: create subtasks with mapped parent IDs
const subtasks = section.tasks.filter((t) => t.parentId !== null);
for (const task of subtasks) {
const newParentId = taskIdMap.get(task.parentId!);
if (!newParentId) continue; // Parent wasn't created (shouldn't happen)
const newTask = await tx.task.create({
data: {
sectionId: newSection.id,
parentId: newParentId,
title: substitute(task.title) ?? task.title,
description: substitute(task.description),
position: task.position,
priority: task.priority,
dueDate: dueDate(task.dueDaysOffset),
},
select: { id: true },
});
taskIdMap.set(task.id, newTask.id);
}
}
// 4. Increment template usage counter
await tx.projectTemplate.update({
where: { id: templateId },
data: { usageCount: { increment: 1 } },
});
return { projectId: project.id };
});
}
π SaaS MVP in 8 Weeks β Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment β all handled by one senior team.
- Week 1β2: Architecture design + wireframes
- Week 3β6: Core features built + tested
- Week 7β8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Server Action: Create Project from Template
// app/actions/templates.ts
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { cloneTemplateToProject } from "@/lib/templates/clone";
import { checkProjectLimit } from "@/lib/plans/enforce";
import { redirect } from "next/navigation";
import { z } from "zod";
const CreateFromTemplateSchema = z.object({
templateId: z.string().uuid(),
projectName: z.string().min(1).max(100).trim(),
startDate: z.string().optional(),
});
export async function createFromTemplate(
input: z.infer<typeof CreateFromTemplateSchema>
): Promise<{ error?: string; upgradeRequired?: boolean }> {
const session = await auth();
if (!session?.user) return { error: "Unauthorized" };
const parsed = CreateFromTemplateSchema.safeParse(input);
if (!parsed.success) return { error: parsed.error.issues[0].message };
// Check plan limit
const workspace = await prisma.workspace.findUniqueOrThrow({
where: { id: session.user.workspaceId },
select: { plan: true },
});
const limitCheck = await checkProjectLimit(session.user.workspaceId, workspace.plan as any);
if (!limitCheck.allowed) {
return { error: `Project limit reached (${limitCheck.current}/${limitCheck.limit})`, upgradeRequired: true };
}
const { projectId } = await cloneTemplateToProject(
parsed.data.templateId,
session.user.workspaceId,
session.user.id,
{
projectName: parsed.data.projectName,
startDate: parsed.data.startDate,
ownerId: session.user.id,
}
);
redirect(`/projects/${projectId}`);
}
Template Gallery UI
// app/templates/page.tsx
import { prisma } from "@/lib/prisma";
import { auth } from "@/auth";
import { TemplateCard } from "@/components/templates/template-card";
const CATEGORIES = ["all", "engineering", "marketing", "design", "ops"] as const;
export default async function TemplatesPage({
searchParams,
}: {
searchParams: { category?: string };
}) {
const session = await auth();
const category = searchParams.category ?? "all";
const templates = await prisma.projectTemplate.findMany({
where: {
OR: [
{ isPublic: true },
{ workspaceId: session!.user.workspaceId },
],
...(category !== "all" && { category }),
},
orderBy: [{ isPublic: "asc" }, { usageCount: "desc" }],
select: {
id: true, name: true, description: true, category: true,
isPublic: true, usageCount: true, previewData: true,
sections: { select: { _count: { select: { tasks: true } } } },
},
});
return (
<div className="max-w-6xl mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Project Templates</h1>
<p className="text-gray-500 mt-1">Start faster with a pre-built project structure</p>
</div>
{/* Category tabs */}
<div className="flex gap-2 mb-6 overflow-x-auto pb-1">
{CATEGORIES.map((cat) => (
<a
key={cat}
href={cat === "all" ? "/templates" : `/templates?category=${cat}`}
className={`px-4 py-1.5 rounded-full text-sm font-medium whitespace-nowrap capitalize transition-colors ${
category === cat
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{cat}
</a>
))}
</div>
{/* Template grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map((template) => (
<TemplateCard key={template.id} template={template} />
))}
</div>
</div>
);
}
// components/templates/template-card.tsx
"use client";
import { useState, useTransition } from "react";
import { createFromTemplate } from "@/app/actions/templates";
import { Layers, Users, ArrowRight } from "lucide-react";
interface TemplateCardProps {
template: {
id: string;
name: string;
description: string | null;
category: string;
isPublic: boolean;
usageCount: number;
sections: { _count: { tasks: number } }[];
};
}
export function TemplateCard({ template }: TemplateCardProps) {
const [showForm, setShowForm] = useState(false);
const [projectName, setName] = useState(template.name);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const totalTasks = template.sections.reduce((sum, s) => sum + s._count.tasks, 0);
function handleCreate() {
setError(null);
startTransition(async () => {
const result = await createFromTemplate({
templateId: template.id,
projectName,
});
if (result?.error) setError(result.error);
});
}
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between mb-3">
<div>
<span className="text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full capitalize">
{template.category}
</span>
{template.isPublic && (
<span className="ml-2 text-xs text-gray-400">Official</span>
)}
</div>
<span className="text-xs text-gray-400 flex items-center gap-1">
<Users className="w-3 h-3" />
{template.usageCount.toLocaleString()} uses
</span>
</div>
<h3 className="font-semibold text-gray-900 mb-1">{template.name}</h3>
{template.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{template.description}</p>
)}
<div className="flex items-center gap-3 text-xs text-gray-400 mb-4">
<span className="flex items-center gap-1">
<Layers className="w-3 h-3" />
{template.sections.length} sections
</span>
<span>{totalTasks} tasks</span>
</div>
{!showForm ? (
<button
onClick={() => setShowForm(true)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700"
>
Use template <ArrowRight className="w-4 h-4" />
</button>
) : (
<div className="space-y-3">
<input
value={projectName}
onChange={(e) => setName(e.target.value)}
placeholder="Project name"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={isPending || !projectName.trim()}
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg disabled:opacity-50"
>
{isPending ? "Creatingβ¦" : "Create project"}
</button>
<button
onClick={() => setShowForm(false)}
className="px-3 py-2 border border-gray-200 text-gray-600 text-sm rounded-lg hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
)}
</div>
);
}
π‘ The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments β with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity β you own everything
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Schema + basic clone (flat tasks) | 1 dev | 1β2 days | $400β800 |
| Deep copy with subtasks + variables | 1β2 devs | 3β4 days | $1,200β2,400 |
| Template gallery UI + category filter | 1 dev | 2 days | $600β1,200 |
| + Custom template creation by users | 1β2 devs | 1 week | $3,000β5,000 |
See Also
- SaaS Onboarding Flow
- SaaS Plan Limits
- PostgreSQL Constraints and Validation
- React Data Tables with TanStack
- Next.js Server Actions Patterns
Working With Viprasol
Templates are a high-leverage onboarding feature that most teams under-invest in. Our team builds template systems with deep-copy cloning (handling subtask parent ID remapping in two passes), {{variable}} substitution for project name and owner, relative due date offsets from project start, usage count tracking for popularity sorting, and a gallery UI with category filtering.
What we deliver:
project_templates+template_sections+template_tasksschema withdue_days_offsetand parent_id for subtaskscloneTemplateToProject: two-pass task creation (top-level then subtasks) withtaskIdMapfor parent remappingsubstitute()helper:{{project_name}},{{owner}},{{start_date}}regex replacedueDate()helper: startDate + offsetDays absolute datecreateFromTemplateServer Action: plan limit check, Zod validation, redirect to new project- Template gallery: category tabs,
isPublic+ workspace filter,usageCountsort TemplateCard: inline name form,useTransitioncreate, error display
Talk to our team about your SaaS onboarding and template system β
Or explore our SaaS development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours β fast.
Free consultation β’ No commitment β’ Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow β automating repetitive tasks, qualifying leads, and responding across every channel your customers use.