SaaS Workspace Settings: Profile, Branding, Danger Zone, and Settings Architecture
Build a production workspace settings page for your SaaS. Covers profile update forms, logo upload to S3, custom domain configuration, danger zone with soft delete + email confirmation, and settings page architecture with React Hook Form and Zod.
The settings page is where trust is built or broken. It needs to be reliable, obvious, and forgiving β changes should save cleanly, destructive actions should require confirmation, and errors should be informative. It's also where surprising edge cases live: logo uploads with size limits, custom domain validation, and the "delete workspace" flow that must work even when the user is emotional.
This guide covers the complete settings page architecture with form handling, logo upload, custom domain configuration, and a properly-gated danger zone.
Database Schema
-- Workspace settings (normalized off the main workspaces table)
ALTER TABLE workspaces ADD COLUMN description TEXT;
ALTER TABLE workspaces ADD COLUMN logo_url TEXT;
ALTER TABLE workspaces ADD COLUMN website_url TEXT;
ALTER TABLE workspaces ADD COLUMN custom_domain TEXT UNIQUE;
ALTER TABLE workspaces ADD COLUMN custom_domain_verified BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE workspaces ADD COLUMN custom_domain_token TEXT; -- CNAME verification token
ALTER TABLE workspaces ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC';
ALTER TABLE workspaces ADD COLUMN deleted_at TIMESTAMPTZ;
CREATE INDEX idx_workspaces_custom_domain ON workspaces(custom_domain)
WHERE custom_domain IS NOT NULL AND custom_domain_verified = TRUE;
Settings Page Structure
// app/(app)/settings/page.tsx β tabbed settings layout
import { SettingsNav } from "@/components/settings/settings-nav";
import { WorkspaceProfileForm } from "@/components/settings/workspace-profile-form";
import { BrandingSection } from "@/components/settings/branding-section";
import { CustomDomainSection } from "@/components/settings/custom-domain-section";
import { DangerZone } from "@/components/settings/danger-zone";
import { getWorkspace } from "@/lib/workspace";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function SettingsPage() {
const session = await auth();
if (!session?.user) redirect("/login");
if (!["owner", "admin"].includes(session.user.role)) redirect("/dashboard");
const workspace = await getWorkspace(session.user.workspaceId);
return (
<div className="max-w-2xl mx-auto py-10 px-4 space-y-12">
<div>
<h1 className="text-2xl font-bold text-gray-900">Workspace settings</h1>
<p className="text-sm text-gray-500 mt-1">
Manage your workspace name, branding, and configuration.
</p>
</div>
<WorkspaceProfileForm workspace={workspace} />
<BrandingSection workspace={workspace} />
<CustomDomainSection workspace={workspace} />
{session.user.role === "owner" && (
<DangerZone workspace={workspace} />
)}
</div>
);
}
π 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
Workspace Profile Form
// components/settings/workspace-profile-form.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useState } from "react";
import { useRouter } from "next/navigation";
const ProfileSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(50),
slug: z
.string()
.min(2)
.max(40)
.regex(/^[a-z0-9-]+$/, "Slug must be lowercase letters, numbers, and hyphens only"),
description: z.string().max(500).optional(),
websiteUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
timezone: z.string(),
});
type ProfileFormData = z.infer<typeof ProfileSchema>;
interface WorkspaceProfileFormProps {
workspace: {
id: string;
name: string;
slug: string;
description: string | null;
websiteUrl: string | null;
timezone: string;
};
}
const TIMEZONES = Intl.supportedValuesOf("timeZone"); // Modern browser API
export function WorkspaceProfileForm({ workspace }: WorkspaceProfileFormProps) {
const router = useRouter();
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset,
} = useForm<ProfileFormData>({
resolver: zodResolver(ProfileSchema),
defaultValues: {
name: workspace.name,
slug: workspace.slug,
description: workspace.description ?? "",
websiteUrl: workspace.websiteUrl ?? "",
timezone: workspace.timezone,
},
});
async function onSubmit(data: ProfileFormData) {
setSaveState("saving");
try {
const res = await fetch(`/api/workspaces/${workspace.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error ?? "Save failed");
}
setSaveState("saved");
reset(data); // Reset dirty state
router.refresh(); // Revalidate server data
setTimeout(() => setSaveState("idle"), 3000);
} catch (err) {
setSaveState("error");
setTimeout(() => setSaveState("idle"), 5000);
}
}
return (
<section>
<h2 className="text-base font-semibold text-gray-900 mb-4">Workspace profile</h2>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5 bg-white border border-gray-200 rounded-xl p-6">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Workspace name <span className="text-red-500">*</span>
</label>
<input
{...register("name")}
type="text"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.name && (
<p className="text-xs text-red-600 mt-1">{errors.name.message}</p>
)}
</div>
{/* Slug */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
URL slug
</label>
<div className="flex items-center">
<span className="text-sm text-gray-400 bg-gray-50 border border-r-0 border-gray-300 rounded-l-lg px-3 py-2">
app.viprasol.com/
</span>
<input
{...register("slug")}
type="text"
className="flex-1 border border-gray-300 rounded-r-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{errors.slug && (
<p className="text-xs text-red-600 mt-1">{errors.slug.message}</p>
)}
<p className="text-xs text-gray-500 mt-1">
Changing your slug will break any existing links.
</p>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
{...register("description")}
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
placeholder="What does your workspace do?"
/>
</div>
{/* Website */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Website</label>
<input
{...register("websiteUrl")}
type="url"
placeholder="https://"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{errors.websiteUrl && (
<p className="text-xs text-red-600 mt-1">{errors.websiteUrl.message}</p>
)}
</div>
{/* Timezone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Timezone</label>
<select
{...register("timezone")}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>{tz.replace(/_/g, " ")}</option>
))}
</select>
</div>
<div className="flex items-center justify-between pt-2">
<button
type="submit"
disabled={!isDirty || saveState === "saving"}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{saveState === "saving" ? "Savingβ¦" : "Save changes"}
</button>
{saveState === "saved" && (
<p className="text-sm text-green-600">β Saved successfully</p>
)}
{saveState === "error" && (
<p className="text-sm text-red-600">Save failed β try again</p>
)}
</div>
</form>
</section>
);
}
Logo Upload to S3
// app/api/workspaces/[id]/logo/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { nanoid } from "nanoid";
const s3 = new S3Client({ region: process.env.AWS_REGION! });
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/svg+xml"];
const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
// Returns a presigned S3 URL for direct browser upload
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
const session = await auth();
if (!session?.user || !["owner", "admin"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { contentType, size } = await req.json();
if (!ALLOWED_TYPES.includes(contentType)) {
return NextResponse.json({ error: "File type not allowed. Use JPEG, PNG, WebP, or SVG." }, { status: 400 });
}
if (size > MAX_SIZE_BYTES) {
return NextResponse.json({ error: "File too large. Maximum size is 2MB." }, { status: 400 });
}
const ext = contentType.split("/")[1].replace("svg+xml", "svg");
const key = `workspaces/${params.id}/logo-${nanoid(8)}.${ext}`;
const presignedUrl = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME!,
Key: key,
ContentType: contentType,
ContentLength: size,
// Prevent public-read β use CloudFront signed URLs or public bucket for logos
ACL: "public-read",
}),
{ expiresIn: 300 } // 5 minutes
);
const publicUrl = `https://${process.env.S3_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
return NextResponse.json({ presignedUrl, publicUrl, key });
}
// components/settings/logo-upload.tsx β client component
"use client";
import { useState, useRef } from "react";
import { Upload, X } from "lucide-react";
import Image from "next/image";
interface LogoUploadProps {
workspaceId: string;
currentLogoUrl: string | null;
onUploadComplete: (url: string) => void;
}
export function LogoUpload({ workspaceId, currentLogoUrl, onUploadComplete }: LogoUploadProps) {
const [preview, setPreview] = useState<string | null>(currentLogoUrl);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFile(file: File) {
setError(null);
setUploading(true);
try {
// 1. Get presigned URL from server
const metaRes = await fetch(`/api/workspaces/${workspaceId}/logo`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, size: file.size }),
});
if (!metaRes.ok) {
const json = await metaRes.json();
throw new Error(json.error);
}
const { presignedUrl, publicUrl } = await metaRes.json();
// 2. Upload directly to S3 (no server roundtrip for file bytes)
const uploadRes = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": file.type },
});
if (!uploadRes.ok) throw new Error("Upload to S3 failed");
// 3. Save URL to workspace
await fetch(`/api/workspaces/${workspaceId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ logoUrl: publicUrl }),
});
setPreview(publicUrl);
onUploadComplete(publicUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed");
} finally {
setUploading(false);
}
}
return (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Workspace logo</p>
<div className="flex items-center gap-4">
{/* Preview */}
<div className="w-16 h-16 rounded-xl border border-gray-200 bg-gray-50 flex items-center justify-center overflow-hidden flex-shrink-0">
{preview ? (
<Image src={preview} alt="Workspace logo" width={64} height={64} className="object-contain" />
) : (
<span className="text-2xl text-gray-400">π’</span>
)}
</div>
<div className="space-y-1">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/svg+xml"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{uploading ? "Uploadingβ¦" : "Upload logo"}
</button>
<p className="text-xs text-gray-400">JPEG, PNG, WebP or SVG. Max 2MB.</p>
{error && <p className="text-xs text-red-600">{error}</p>}
</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
Danger Zone: Delete Workspace
// components/settings/danger-zone.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle } from "lucide-react";
interface DangerZoneProps {
workspace: { id: string; name: string };
}
export function DangerZone({ workspace }: DangerZoneProps) {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const isConfirmed = confirmText === workspace.name;
async function handleDelete() {
if (!isConfirmed) return;
setDeleting(true);
setError(null);
try {
const res = await fetch(`/api/workspaces/${workspace.id}`, {
method: "DELETE",
});
if (!res.ok) {
const json = await res.json();
throw new Error(json.error ?? "Delete failed");
}
router.push("/goodbye"); // Post-deletion landing page
} catch (err) {
setError(err instanceof Error ? err.message : "Delete failed");
setDeleting(false);
}
}
return (
<section>
<h2 className="text-base font-semibold text-red-600 mb-4">Danger zone</h2>
<div className="border border-red-200 rounded-xl p-6 space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-gray-900">Delete this workspace</p>
<p className="text-sm text-gray-500 mt-1">
Permanently delete <strong>{workspace.name}</strong> and all its data.
This action cannot be undone.
</p>
</div>
<button
onClick={() => setIsOpen(true)}
className="px-3 py-1.5 text-sm font-medium text-red-600 border border-red-300 rounded-lg hover:bg-red-50 flex-shrink-0 ml-4"
>
Delete workspace
</button>
</div>
</div>
{/* Confirmation modal */}
{isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-semibold text-gray-900">Delete workspace</h3>
<p className="text-sm text-gray-500 mt-1">
This will permanently delete all projects, members, billing data, and settings
associated with <strong>{workspace.name}</strong>.
</p>
</div>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-700 mb-1">
Type <strong className="font-semibold">{workspace.name}</strong> to confirm
</label>
<input
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={workspace.name}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
autoFocus
/>
</div>
{error && (
<p className="text-sm text-red-600 mb-4">{error}</p>
)}
<div className="flex gap-3 justify-end">
<button
onClick={() => { setIsOpen(false); setConfirmText(""); }}
disabled={deleting}
className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={!isConfirmed || deleting}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleting ? "Deletingβ¦" : "Delete workspace"}
</button>
</div>
</div>
</div>
)}
</section>
);
}
API: PATCH and DELETE Workspace
// app/api/workspaces/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
const PatchSchema = z.object({
name: z.string().min(2).max(50).optional(),
slug: z.string().min(2).max(40).regex(/^[a-z0-9-]+$/).optional(),
description: z.string().max(500).optional(),
websiteUrl: z.string().url().optional().or(z.literal("")),
timezone: z.string().optional(),
logoUrl: z.string().url().optional(),
}).strict();
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
const session = await auth();
if (!session?.user || !["owner", "admin"].includes(session.user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await req.json();
const parsed = PatchSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0].message }, { status: 400 });
}
// Slug uniqueness check
if (parsed.data.slug) {
const existing = await prisma.workspace.findFirst({
where: { slug: parsed.data.slug, id: { not: params.id } },
});
if (existing) {
return NextResponse.json({ error: "This slug is already taken" }, { status: 409 });
}
}
const workspace = await prisma.workspace.update({
where: { id: params.id },
data: parsed.data,
});
return NextResponse.json(workspace);
}
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const session = await auth();
if (!session?.user || session.user.role !== "owner") {
return NextResponse.json({ error: "Only the workspace owner can delete the workspace" }, { status: 403 });
}
// Soft delete β hard delete runs via scheduled job after 30 days
await prisma.workspace.update({
where: { id: params.id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
Cost and Timeline Estimates
| Scope | Team | Timeline | Cost Range |
|---|---|---|---|
| Settings page (profile + logo upload) | 1 dev | 3β5 days | $800β1,500 |
| + Custom domain + danger zone | 1 dev | 1β2 weeks | $2,000β4,000 |
| + Audit log for settings changes | 1 dev | 2β3 days | $600β1,200 |
See Also
- SaaS Multi-Workspace Architecture
- SaaS Role-Based Access Control
- React Hook Form with Zod Validation
- SaaS Audit Logging and Trail
- SaaS Team Invitations Flow
Working With Viprasol
Settings pages look simple but hide real complexity β presigned S3 uploads, slug-change warnings, timezone handling, soft-delete with confirmation flows, and permission gates that differ by role. Our team builds settings UIs that are intuitive, safe, and extensible as your product grows.
What we deliver:
- React Hook Form + Zod profile form with slug uniqueness validation
- Presigned S3 upload flow (client β presigned URL β S3 direct upload)
- Logo preview and upload component with type/size validation
- Danger zone with type-to-confirm pattern and soft-delete API
- PATCH workspace API with schema validation and 409 conflict on slug collision
Talk to our team about your SaaS settings architecture β
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.