Unstack Pro Docs

Customization Guide

How to customize and extend Unstack Pro for your product

Customization Guide

Customize Unstack Pro to match your brand and product needs. The template is designed to be extended while keeping the core auth and organization features intact.

You don't need to modify auth code to customize your app. Focus on branding, adding features, and extending the schema.

Branding

Site Configuration

Update your app's name, description, and metadata in /config/site.ts:

config/site.ts
export const siteConfig = {
  name: "Your App Name",
  description: "Your app description for SEO and social sharing",
  url: "https://yourdomain.com",
  ogImage: "https://yourdomain.com/og.png",
  links: {
    twitter: "https://twitter.com/yourapp",
    github: "https://github.com/yourorg/yourapp",
  },
};

Logo and Favicon

Replace the default logos:

  1. Favicon: Replace /app/favicon.ico
  2. Logo: Add your logo to /public/logo.png and /public/logo-dark.png
  3. Update components that reference the logo
components/logo.tsx
import Image from "next/image";

export function Logo() {
  return (
    <>
      <Image
        src="/logo.png"
        alt="Your App"
        width={120}
        height={40}
        className="dark:hidden"
      />
      <Image
        src="/logo-dark.png"
        alt="Your App"
        width={120}
        height={40}
        className="hidden dark:block"
      />
    </>
  );
}

Colors and Theme

Unstack Pro uses Tailwind CSS v4 with CSS variables for theming. Customize colors in /styles/globals.css:

styles/globals.css
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 240 10% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 240 10% 3.9%;
    --primary: 240 5.9% 10%;
    --primary-foreground: 0 0% 98%;
    --secondary: 240 4.8% 95.9%;
    --secondary-foreground: 240 5.9% 10%;
    --muted: 240 4.8% 95.9%;
    --muted-foreground: 240 3.8% 46.1%;
    --accent: 240 4.8% 95.9%;
    --accent-foreground: 240 5.9% 10%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 0 0% 98%;
    --border: 240 5.9% 90%;
    --input: 240 5.9% 90%;
    --ring: 240 5.9% 10%;
    --radius: 0.5rem;
  }

  .dark {
    --background: 240 10% 3.9%;
    --foreground: 0 0% 98%;
    --card: 240 10% 3.9%;
    --card-foreground: 0 0% 98%;
    --popover: 240 10% 3.9%;
    --popover-foreground: 0 0% 98%;
    --primary: 0 0% 98%;
    --primary-foreground: 240 5.9% 10%;
    --secondary: 240 3.7% 15.9%;
    --secondary-foreground: 0 0% 98%;
    --muted: 240 3.7% 15.9%;
    --muted-foreground: 240 5% 64.9%;
    --accent: 240 3.7% 15.9%;
    --accent-foreground: 0 0% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 0 0% 98%;
    --border: 240 3.7% 15.9%;
    --input: 240 3.7% 15.9%;
    --ring: 240 4.9% 83.9%;
  }
}

Fonts

Customize fonts in /config/fonts.ts:

config/fonts.ts
import { Inter, JetBrains_Mono } from "next/font/google";

export const fontSans = Inter({
  subsets: ["latin"],
  variable: "--font-sans",
});

export const fontMono = JetBrains_Mono({
  subsets: ["latin"],
  variable: "--font-mono",
});

Adding OAuth Providers

Better Auth supports many OAuth providers. To add one:

Configure the Provider

Add a socialProviders block to createAuthOptions in convex/betterAuth/auth.ts — this is the source of truth for auth config (Better Auth runs inside Convex):

convex/betterAuth/auth.ts
// inside createAuthOptions(...)
socialProviders: {
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  },
  github: {
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  },
},

No social providers are configured out of the box — the stack is OAuth-ready. Because these env vars are read in the Convex runtime, set them with bunx convex env set (see below).

Add Environment Variables

Add OAuth credentials to your environment:

.env.local
# Google OAuth
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

# GitHub OAuth
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"

Don't forget to also set these in Convex:

bunx convex env set GOOGLE_CLIENT_ID "your-google-client-id"
bunx convex env set GOOGLE_CLIENT_SECRET "your-google-client-secret"

Add Sign-In Button

Add the OAuth button to your login page:

app/auth/login/_components/oauth-buttons.tsx
"use client";

import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { FaGoogle, FaGithub } from "react-icons/fa";

export function OAuthButtons() {
  const signInWithGoogle = async () => {
    await authClient.signIn.social({
      provider: "google",
      callbackURL: "/",
    });
  };

  const signInWithGithub = async () => {
    await authClient.signIn.social({
      provider: "github",
      callbackURL: "/",
    });
  };

  return (
    <div className="grid gap-2">
      <Button variant="outline" onClick={signInWithGoogle}>
        <FaGoogle className="mr-2 h-4 w-4" />
        Continue with Google
      </Button>
      <Button variant="outline" onClick={signInWithGithub}>
        <FaGithub className="mr-2 h-4 w-4" />
        Continue with GitHub
      </Button>
    </div>
  );
}

Configure OAuth App

Set up the OAuth app with the provider:

Google:

  1. Go to Google Cloud Console
  2. Create OAuth 2.0 credentials
  3. Add authorized redirect URI: https://yourdomain.com/api/auth/callback/google

GitHub:

  1. Go to GitHub Developer Settings
  2. Create new OAuth App
  3. Set callback URL: https://yourdomain.com/api/auth/callback/github

For a full list of supported providers, see the Better Auth Social Providers documentation.

Adding New Pages

Create a New Route

Add pages using Next.js App Router conventions:

app/dashboard/page.tsx
import { requireSession } from "@/lib/session";

export default async function DashboardPage() {
  const session = await requireSession();

  return (
    <div className="container py-8">
      <h1 className="text-3xl font-bold">Dashboard</h1>
      <p>Welcome, {session.user.name}!</p>
    </div>
  );
}

Protected Routes

Use requireSession() to protect routes - it automatically redirects to the login page if the user is not authenticated:

app/dashboard/layout.tsx
import { requireSession } from "@/lib/session";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  await requireSession();

  return <>{children}</>;
}

Organization-Scoped Routes

Access organization context in routes:

app/organizations/[organizationSlug]/projects/page.tsx
import { requireSession } from "@/lib/session";

interface Props {
  params: { organizationSlug: string };
}

export default async function ProjectsPage({ params }: Props) {
  const session = await requireSession();
  const { organizationSlug } = params;

  // Fetch organization-specific data
  // Verify user has access to this organization

  return (
    <div>
      <h1>Projects for {organizationSlug}</h1>
      {/* Your content */}
    </div>
  );
}

Customizing Auth Flows

Custom Email Templates

Modify email templates in /emails/:

emails/verification-email.tsx
import { Body, Container, Heading, Html, Text } from "@react-email/components";

export default function VerificationEmail({ otp }: { otp: string }) {
  return (
    <Html>
      <Body style={{ fontFamily: "sans-serif" }}>
        <Container>
          <Heading>Welcome to Your App!</Heading>
          <Text>Your verification code is: <strong>{otp}</strong></Text>
          <Text>This code expires in 10 minutes.</Text>
        </Container>
      </Body>
    </Html>
  );
}

Custom Validation Rules

Extend Zod schemas for custom validation:

schemas/auth.ts
import { z } from "zod";

export const registerSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  password: z
    .string()
    .min(8, "Password must be at least 8 characters")
    .regex(/[A-Z]/, "Password must contain an uppercase letter")
    .regex(/[0-9]/, "Password must contain a number"),
});

export const customProfileSchema = z.object({
  name: z.string().min(2),
  company: z.string().optional(),
  jobTitle: z.string().optional(),
  website: z.string().url().optional(),
});

Removing Optional Features

Remove Autumn (Billing)

If you don't need billing:

  1. Remove AUTUMN_SECRET_KEY from environment variables
  2. Delete billing-related routes in /app/organizations/[organizationSlug]/billing/
  3. Remove billing UI components
  4. Update organization creation to not require billing

Remove Sentry (Error Monitoring)

Sentry is environment-variable driven — the SDK no-ops when NEXT_PUBLIC_SENTRY_DSN is empty, and builds skip source-map upload when SENTRY_AUTH_TOKEN is missing. To simply disable it, leave those variables unset; no code changes are required.

To remove it entirely:

  1. Delete instrumentation.ts, instrumentation-client.ts, sentry.edge.config.ts, and sentry.server.config.ts
  2. Remove the Sentry wrapper from next.config.js
  3. Drop the NEXT_PUBLIC_SENTRY_DSN / SENTRY_* env vars
  4. Uninstall the package:
bun remove @sentry/nextjs

Remove Passkeys

If you don't need passkey authentication:

  1. Remove passkey routes in /app/account/security/passkeys/
  2. Remove passkey components
  3. Disable passkey plugin in Better Auth config:
lib/auth-server.ts
export const auth = betterAuth({
  // Remove or comment out passkey plugin
  // plugins: [passkey()],
});

Adding New Components

Create Reusable Components

Add to /components/ for shared components:

components/stats-card.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

interface StatsCardProps {
  title: string;
  value: string | number;
  description?: string;
  icon?: React.ReactNode;
}

export function StatsCard({ title, value, description, icon }: StatsCardProps) {
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium">{title}</CardTitle>
        {icon}
      </CardHeader>
      <CardContent>
        <div className="text-2xl font-bold">{value}</div>
        {description && (
          <p className="text-xs text-muted-foreground">{description}</p>
        )}
      </CardContent>
    </Card>
  );
}

Use Shadcn UI Components

Add new Shadcn components as needed:

bunx shadcn@latest add chart
bunx shadcn@latest add calendar
bunx shadcn@latest add command

Custom Hooks

Add custom hooks in /lib/hooks/:

lib/hooks/use-organization.ts
"use client";

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

export function useOrganization(slug: string) {
  const organization = useQuery(api.organizations.getBySlug, { slug });
  const members = useQuery(api.organizations.getMembers, { slug });

  return {
    organization,
    members,
    isLoading: organization === undefined,
  };
}

Environment-Specific Configuration

Development vs Production

Use environment checks for different behavior:

const isDev = process.env.NODE_ENV === "development";

export const config = {
  apiUrl: isDev
    ? "http://localhost:3000/api"
    : "https://yourdomain.com/api",
  enableDebugLogs: isDev,
  mockPayments: isDev,
};

The template ships starter Terms of Service and Privacy Policy pages so you have something to link from the footer and the register screen on day one:

app/(legal)/
├── layout.tsx          # Shared minimal legal layout
├── terms/page.tsx      # Terms of Service (starter copy)
└── privacy/page.tsx    # Privacy Policy (starter copy)

Company/app name and the contact address are parameterized through config/site.ts (appName, supportEmail), so you mostly edit copy rather than markup.

The starter copy is a scaffold, not legal advice. Before going to production, review and replace the Terms and Privacy copy with your own (ideally with counsel), set NEXT_PUBLIC_SUPPORT_EMAIL to your real support address, and confirm the listed data processors and retention/refund terms match your business.

Demo Video Tooling (Bonus)

The template includes a two-stage pipeline that turns the real app into an edited, launch-style demo video — a bonus you can use or delete.

# One-time setup
bun run demo:install-browsers    # Playwright Chromium
cd video && bun install && cd .. # the Remotion project has its own deps

# With `bun convex dev` and `bun dev` running:
DEMO_EMAIL=you@example.com DEMO_PASSWORD=your-password bun run demo:render
  • bun run demo:capture — Playwright drives the app and captures real screens into video/public/demo (see the header comment in scripts/capture-demo.ts for the full list of DEMO_* env vars).
  • bun run demo:video — renders the Remotion composition in video/.
  • bun run demo:render — runs both stages; output is demo-output/unstack-pro-demo-latest.mp4.

The video/ Remotion project is a self-contained package with its own dependencies — run bun install inside it once before rendering. See the product README's "Scripted Demo Video" section for details.

Best Practices

  1. Don't modify core auth files unless necessary - extend instead
  2. Keep components small and focused on one responsibility
  3. Use TypeScript strictly for type safety
  4. Follow existing patterns for consistency
  5. Test changes locally before deploying
  6. Document custom code for future reference
  7. Use environment variables for all configuration
  8. Keep secrets secure - never commit them

Resources

Next Steps

On this page