Back to Blog

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.

Viprasol Tech Team
May 31, 2027
12 min read

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

ScopeTeamTimelineCost Range
Schema + basic clone (flat tasks)1 dev1–2 days$400–800
Deep copy with subtasks + variables1–2 devs3–4 days$1,200–2,400
Template gallery UI + category filter1 dev2 days$600–1,200
+ Custom template creation by users1–2 devs1 week$3,000–5,000

See Also


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_tasks schema with due_days_offset and parent_id for subtasks
  • cloneTemplateToProject: two-pass task creation (top-level then subtasks) with taskIdMap for parent remapping
  • substitute() helper: {{project_name}}, {{owner}}, {{start_date}} regex replace
  • dueDate() helper: startDate + offsetDays absolute date
  • createFromTemplate Server Action: plan limit check, Zod validation, redirect to new project
  • Template gallery: category tabs, isPublic + workspace filter, usageCount sort
  • TemplateCard: inline name form, useTransition create, error display

Talk to our team about your SaaS onboarding and template system β†’

Or explore our SaaS development services.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

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.