Skip to main content
Cookie preferences

We use analytics cookies to understand usage and improve CleanTextLab. You can accept or decline Privacy policy. Manage preferences.

Back to Docs
System Documentation

Patterns

Reusable Code Patterns - CleanTextLab

Last Updated: January 5, 2026 Purpose: Document common patterns used throughout the codebase Audience: Developers (human or AI) working on CleanTextLab


Table of Contents

  1. Tool Component Pattern
  2. Custom Hooks Pattern
  3. Keyboard Shortcuts Pattern
  4. Share/Export Pattern
  5. Magic Input Pattern
  6. Workflow Builder Pattern
  7. Theme System Pattern
  8. Analytics Tracking Pattern
  9. Error Handling Pattern
  10. Pro Feature Gating Pattern
  11. UI Density (Compact Mode) Pattern
  12. Mobile Options Sheet Pattern

Tool Component Pattern

Use Case

Every tool in CleanTextLab follows a standardized component structure for consistency, maintainability, and UX uniformity.

Full Pattern

"use client";

import { useState, useMemo, useCallback } from "react";
import { Copy, Check, Download, Share2, History, Undo2, Redo2 } from "lucide-react";
import { useUndoRedo } from "@/hooks/useUndoRedo";
import { useToolHistory } from "@/hooks/useToolHistory";
import { useShareableTool } from "@/hooks/useShareableTool";
import { trackToolUse } from "@/lib/analytics";

const TOOL_SLUG = "example-tool";
const TOOL_NAME = "Example Tool";

interface ProcessingOptions {
  option1: boolean;
  option2: string;
}

export function ExampleTool() {
  // 1. State Management with Undo/Redo
  const { state: input, setState: setInput, undo, redo, canUndo, canRedo } = useUndoRedo("");

  // 2. Tool-Specific Options
  const [options, setOptions] = useState<ProcessingOptions>({
    option1: true,
    option2: "default",
  });

  // 3. UI State
  const [copied, setCopied] = useState(false);
  const [toastMessage, setToastMessage] = useState("");

  // 4. Processing Logic (Pure Function)
  const output = useMemo(() => {
    if (!input) return "";

    try {
      // Tool-specific processing logic here
      let result = input;

      if (options.option1) {
        result = result.trim();
      }

      // Track usage
      trackToolUse(TOOL_SLUG, {
        inputLength: input.length,
        ...options
      });

      return result;
    } catch (error) {
      console.error(`[${TOOL_NAME}] Processing error:`, error);
      return "";
    }
  }, [input, options]);

  // 5. History Tracking
  const { history, clearHistory } = useToolHistory(
    TOOL_SLUG,
    () => ({
      input,
      output,
      metadata: options,
    }),
    [input, output, options]
  );

  // 6. Share Functionality
  const { handleShare, shareStatus } = useShareableTool({
    toolSlug: TOOL_SLUG,
    sharePayload: output || input,
  });

  // 7. Action Handlers
  const handleCopy = useCallback(async () => {
    if (!output) return;

    try {
      await navigator.clipboard.writeText(output);
      setCopied(true);
      setToastMessage("Copied to clipboard");
      setTimeout(() => setCopied(false), 2000);
    } catch (error) {
      console.error("Copy failed:", error);
      setToastMessage("Failed to copy");
    }
  }, [output]);

  const handleDownload = useCallback(() => {
    if (!output) return;

    const blob = new Blob([output], { type: "text/plain" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `${TOOL_SLUG}-output.txt`;
    a.click();
    URL.revokeObjectURL(url);
  }, [output]);

  const handleUndo = useCallback(() => {
    if (canUndo) {
      undo();
      setToastMessage("Undone");
    }
  }, [canUndo, undo]);

  const handleRedo = useCallback(() => {
    if (canRedo) {
      redo();
      setToastMessage("Redone");
    }
  }, [canRedo, redo]);

  // 8. Render
  return (
    <div className="mx-auto w-full max-w-5xl space-y-6 p-4">
      {/* Toast Notification */}
      {toastMessage && (
        <div className="fixed bottom-4 right-4 z-50 rounded-lg bg-green-600 px-4 py-2 text-white">
          {toastMessage}
        </div>
      )}

      {/* Input Section */}
      <div className="space-y-3">
        <label className="block text-sm font-medium">Input</label>
        <textarea
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Paste your text here..."
          className="h-64 w-full rounded-lg border p-4 font-mono text-sm"
        />

        {/* Options */}
        <div className="flex items-center gap-4">
          <label className="flex items-center gap-2">
            <input
              type="checkbox"
              checked={options.option1}
              onChange={(e) => setOptions({ ...options, option1: e.target.checked })}
            />
            <span className="text-sm">Option 1</span>
          </label>
        </div>

        {/* Undo/Redo Buttons */}
        <div className="flex gap-2">
          <button
            onClick={handleUndo}
            disabled={!canUndo}
            className="inline-flex h-9 items-center gap-2 rounded-lg border px-3 disabled:opacity-50"
          >
            <Undo2 className="h-4 w-4" />
            Undo
          </button>
          <button
            onClick={handleRedo}
            disabled={!canRedo}
            className="inline-flex h-9 items-center gap-2 rounded-lg border px-3 disabled:opacity-50"
          >
            <Redo2 className="h-4 w-4" />
            Redo
          </button>
        </div>
      </div>

      {/* Output Section */}
      {output && (
        <div className="space-y-3">
          <div className="flex items-center justify-between">
            <label className="block text-sm font-medium">Output</label>

            {/* Action Buttons */}
            <div className="flex gap-2">
              <button
                onClick={handleCopy}
                className="inline-flex h-9 items-center gap-2 rounded-lg bg-blue-600 px-4 text-white"
              >
                {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
                {copied ? "Copied" : "Copy"}
              </button>
              <button
                onClick={handleDownload}
                className="inline-flex h-9 items-center gap-2 rounded-lg border px-3"
              >
                <Download className="h-4 w-4" />
              </button>
              <button
                onClick={handleShare}
                className="inline-flex h-9 items-center gap-2 rounded-lg border px-3"
              >
                <Share2 className="h-4 w-4" />
              </button>
            </div>
          </div>

          <textarea
            value={output}
            readOnly
            className="h-64 w-full rounded-lg border bg-gray-50 p-4 font-mono text-sm"
          />
        </div>
      )}
    </div>
  );
}

When to Use

  • Always when creating a new tool
  • When refactoring existing tools for consistency

Common Pitfalls

  • ❌ Using useState instead of useUndoRedo for main input state
  • ❌ Forgetting to memoize output calculation (causes re-renders)
  • ❌ Not tracking analytics in processing logic
  • ❌ Hardcoding tool slug/name (use constants)

Related Patterns


Custom Hooks Pattern

1. useUndoRedo Hook

Purpose: Provide undo/redo functionality with keyboard shortcuts for any stateful component.

Full Code:

"use client";

import { useState, useCallback, useEffect } from "react";

interface UndoRedoState<T> {
  past: T[];
  present: T;
  future: T[];
}

interface UseUndoRedoReturn<T> {
  state: T;
  setState: (newState: T | ((prev: T) => T)) => void;
  undo: () => void;
  redo: () => void;
  canUndo: boolean;
  canRedo: boolean;
  reset: (initialState: T) => void;
}

export function useUndoRedo<T>(
  initialState: T,
  maxHistory: number = 50
): UseUndoRedoReturn<T> {
  const [history, setHistory] = useState<UndoRedoState<T>>({
    past: [],
    present: initialState,
    future: [],
  });

  const setState = useCallback(
    (newState: T | ((prev: T) => T)) => {
      setHistory((currentHistory) => {
        const resolvedState =
          typeof newState === "function"
            ? (newState as (prev: T) => T)(currentHistory.present)
            : newState;

        // Deduplicate: Don't add to history if state hasn't changed
        if (JSON.stringify(resolvedState) === JSON.stringify(currentHistory.present)) {
          return currentHistory;
        }

        const newPast = [...currentHistory.past, currentHistory.present];

        // Limit history stack size
        if (newPast.length > maxHistory) {
          newPast.shift();
        }

        return {
          past: newPast,
          present: resolvedState,
          future: [], // Clear future on new state
        };
      });
    },
    [maxHistory]
  );

  const undo = useCallback(() => {
    setHistory((currentHistory) => {
      if (currentHistory.past.length === 0) return currentHistory;

      const previous = currentHistory.past[currentHistory.past.length - 1];
      const newPast = currentHistory.past.slice(0, currentHistory.past.length - 1);

      return {
        past: newPast,
        present: previous,
        future: [currentHistory.present, ...currentHistory.future],
      };
    });
  }, []);

  const redo = useCallback(() => {
    setHistory((currentHistory) => {
      if (currentHistory.future.length === 0) return currentHistory;

      const next = currentHistory.future[0];
      const newFuture = currentHistory.future.slice(1);

      return {
        past: [...currentHistory.past, currentHistory.present],
        present: next,
        future: newFuture,
      };
    });
  }, []);

  const reset = useCallback((newInitialState: T) => {
    setHistory({
      past: [],
      present: newInitialState,
      future: [],
    });
  }, []);

  // Global keyboard shortcuts
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Cmd+Z (undo)
      if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
        const target = e.target as HTMLElement;

        // Don't interfere with input fields
        if (target.tagName === "INPUT" && target.getAttribute("type") !== "text") return;
        if (target.getAttribute("data-undo-redo-enabled") === "false") return;

        e.preventDefault();
        undo();
      }

      // Cmd+Shift+Z (redo)
      if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
        const target = e.target as HTMLElement;

        if (target.tagName === "INPUT" && target.getAttribute("type") !== "text") return;
        if (target.getAttribute("data-undo-redo-enabled") === "false") return;

        e.preventDefault();
        redo();
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [undo, redo]);

  return {
    state: history.present,
    setState,
    undo,
    redo,
    canUndo: history.past.length > 0,
    canRedo: history.future.length > 0,
    reset,
  };
}

Usage:

const { state: input, setState: setInput, undo, redo, canUndo, canRedo } = useUndoRedo("");

// Update state
setInput("new value");

// Check if undo/redo available
if (canUndo) {
  undo();
}

Common Pitfalls:

  • ❌ Storing large objects (use maxHistory to limit memory)
  • ❌ Not preventing keyboard shortcuts in input fields
  • ❌ Forgetting to handle function updates in setState

2. useToolHistory Hook

Purpose: Track tool usage history with localStorage and cloud sync.

Implementation Pattern:

"use client";

import { useState, useEffect, useCallback } from "react";

interface HistoryEntry<T> {
  id: string;
  timestamp: number;
  data: T;
}

export function useToolHistory<T>(
  toolSlug: string,
  getCurrentState: () => T,
  maxEntries: number = 50
) {
  const [history, setHistory] = useState<HistoryEntry<T>[]>([]);

  // Load history from localStorage on mount
  useEffect(() => {
    const key = `clt-history-${toolSlug}`;
    const stored = localStorage.getItem(key);

    if (stored) {
      try {
        const parsed = JSON.parse(stored);
        setHistory(parsed);
      } catch (error) {
        console.error(`Failed to parse history for ${toolSlug}:`, error);
      }
    }
  }, [toolSlug]);

  // Save to history
  const saveToHistory = useCallback(() => {
    const entry: HistoryEntry<T> = {
      id: crypto.randomUUID(),
      timestamp: Date.now(),
      data: getCurrentState(),
    };

    setHistory((prev) => {
      const updated = [entry, ...prev].slice(0, maxEntries);

      // Persist to localStorage
      const key = `clt-history-${toolSlug}`;
      localStorage.setItem(key, JSON.stringify(updated));

      return updated;
    });
  }, [toolSlug, getCurrentState, maxEntries]);

  // Clear all history
  const clearHistory = useCallback(() => {
    setHistory([]);
    const key = `clt-history-${toolSlug}`;
    localStorage.removeItem(key);
  }, [toolSlug]);

  return {
    history,
    saveToHistory,
    clearHistory,
  };
}

3. useShareableTool Hook

Purpose: Generate shareable links with compressed state in URL.

Pattern:

"use client";

import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";

interface UseShareableToolParams {
  toolSlug: string;
  sharePayload: string;
}

export function useShareableTool({ toolSlug, sharePayload }: UseShareableToolParams) {
  const [shareStatus, setShareStatus] = useState<"idle" | "copying" | "copied">("idle");
  const router = useRouter();

  const handleShare = useCallback(async () => {
    try {
      // Compress payload (base64 + gzip in production)
      const encoded = btoa(sharePayload);

      // Generate share URL
      const shareUrl = `${window.location.origin}/tools/${toolSlug}?share=${encoded}`;

      // Copy to clipboard
      await navigator.clipboard.writeText(shareUrl);

      setShareStatus("copied");
      setTimeout(() => setShareStatus("idle"), 2000);
    } catch (error) {
      console.error("Share failed:", error);
      setShareStatus("idle");
    }
  }, [toolSlug, sharePayload]);

  return {
    handleShare,
    shareStatus,
  };
}

Keyboard Shortcuts Pattern

Global Shortcuts Registration

Location: src/hooks/useKeyboardShortcuts.ts

"use client";

import { useEffect } from "react";

interface KeyboardShortcut {
  key: string;
  ctrlOrCmd?: boolean;
  shift?: boolean;
  alt?: boolean;
  action: () => void;
  description?: string;
}

export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      for (const shortcut of shortcuts) {
        const modifierMatch =
          (!shortcut.ctrlOrCmd || (e.metaKey || e.ctrlKey)) &&
          (!shortcut.shift || e.shiftKey) &&
          (!shortcut.alt || e.altKey);

        if (modifierMatch && e.key.toLowerCase() === shortcut.key.toLowerCase()) {
          // Don't interfere with inputs
          const target = e.target as HTMLElement;
          if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
            return;
          }

          e.preventDefault();
          shortcut.action();
          break;
        }
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [shortcuts]);
}

Usage:

useKeyboardShortcuts([
  { key: "z", ctrlOrCmd: true, action: undo, description: "Undo" },
  { key: "z", ctrlOrCmd: true, shift: true, action: redo, description: "Redo" },
  { key: "c", ctrlOrCmd: true, action: handleCopy, description: "Copy output" },
  { key: "k", ctrlOrCmd: true, action: handleClear, description: "Clear input" },
]);

Share/Export Pattern

Standard Share/Copy UI

  • Prefer ToolOutputActions (Share + Copy output) for consistent UX and stable Playwright selectors.
  • If a tool needs a lightweight share menu, render ShareActions somewhere visible in the tool (so Share is always present).
  • Use ActionToast for copy/download feedback (it renders role="status" for accessibility and testability).

Mobile Options Sheet Pattern

Use Case

Some tools have many secondary settings (toggles, dropdowns) that add visual clutter on mobile. Use a bottom-sheet “Options” dialog on mobile to keep the default screen focused on Input → Output → actions.

Implementation

  • Component: src/components/ToolOptionsSheet.tsx
  • Behavior:
    • Mobile (md:hidden): renders an Options button that opens a bottom-sheet (role="dialog", aria-modal="true")
    • Desktop (md:block): renders the options inline (no UX change)
  • Test coverage:
    • e2e/tool-options-sheet.spec.ts

Guideline: only move secondary settings into the sheet. Keep core actions (Copy output / Share) visible.

Multi-Format Export

interface ExportFormat {
  format: "txt" | "json" | "csv" | "pdf";
  mimeType: string;
  extension: string;
}

const EXPORT_FORMATS: Record<string, ExportFormat> = {
  txt: { format: "txt", mimeType: "text/plain", extension: "txt" },
  json: { format: "json", mimeType: "application/json", extension: "json" },
  csv: { format: "csv", mimeType: "text/csv", extension: "csv" },
  pdf: { format: "pdf", mimeType: "application/pdf", extension: "pdf" },
};

async function handleExport(
  data: string,
  format: keyof typeof EXPORT_FORMATS,
  filename: string
) {
  const exportFormat = EXPORT_FORMATS[format];

  let blob: Blob;

  if (format === "pdf") {
    // PDF generation requires server-side or library
    const response = await fetch("/api/v1/export/pdf", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: data }),
    });

    blob = await response.blob();
  } else {
    blob = new Blob([data], { type: exportFormat.mimeType });
  }

  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `${filename}.${exportFormat.extension}`;
  a.click();
  URL.revokeObjectURL(url);
}

Magic Input Pattern

Content Detection & Suggestion

Location: src/lib/magicInput.ts

interface ContentDetectionResult {
  type: "json" | "url" | "email" | "phone" | "code" | "text";
  confidence: number;
  suggestedTool: string;
}

export function detectContentType(input: string): ContentDetectionResult {
  const trimmed = input.trim();

  // JSON detection
  if ((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
      (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
    try {
      JSON.parse(trimmed);
      return {
        type: "json",
        confidence: 1.0,
        suggestedTool: "json-formatter"
      };
    } catch {
      return {
        type: "text",
        confidence: 0.3,
        suggestedTool: "json-formatter"
      };
    }
  }

  // URL detection
  if (/^https?:\/\//i.test(trimmed)) {
    return {
      type: "url",
      confidence: 0.95,
      suggestedTool: "sanitize-url"
    };
  }

  // Phone number detection
  if (/^[\d\s\-\+\(\)]{10,}$/.test(trimmed)) {
    return {
      type: "phone",
      confidence: 0.8,
      suggestedTool: "phone-number-formatter"
    };
  }

  // Base64 detection
  if (/^[A-Za-z0-9+/]+=*$/.test(trimmed) && trimmed.length % 4 === 0) {
    return {
      type: "code",
      confidence: 0.7,
      suggestedTool: "base64-encode-decode"
    };
  }

  // Default to text
  return {
    type: "text",
    confidence: 0.5,
    suggestedTool: "word-counter"
  };
}

Workflow Builder Pattern

Tool Chain Execution

Location: src/lib/workflowEngine.ts

interface WorkflowStep {
  tool: string;
  config: Record<string, any>;
}

interface WorkflowResult {
  success: boolean;
  output: string;
  steps: {
    tool: string;
    input: string;
    output: string;
    duration: number;
  }[];
  error?: string;
}

export async function executeWorkflow(
  input: string,
  steps: WorkflowStep[]
): Promise<WorkflowResult> {
  const result: WorkflowResult = {
    success: true,
    output: input,
    steps: [],
  };

  let currentOutput = input;

  for (const step of steps) {
    const startTime = performance.now();

    try {
      // Import tool processor
      const processor = await import(`./tools/${step.tool}`);

      // Execute step
      const stepOutput = processor.process(currentOutput, step.config);

      const duration = performance.now() - startTime;

      result.steps.push({
        tool: step.tool,
        input: currentOutput,
        output: stepOutput,
        duration,
      });

      currentOutput = stepOutput;
    } catch (error) {
      result.success = false;
      result.error = `Step "${step.tool}" failed: ${error}`;
      break;
    }
  }

  result.output = currentOutput;
  return result;
}

Theme System Pattern

CSS Variables + Data Attributes

Location: src/app/globals.css

/* Light theme (default) */
:root {
  --bg: #f8fafc;
  --surface: #ffffff;
  --surface-muted: #f4f4f5;
  --border: #e4e4e7;
  --text-primary: #0b0b0e;
  --text-secondary: #4b5563;
  --accent: #0f172a;
  --accent-contrast: #ffffff;
}

/* Dark theme */
[data-theme="dark"] {
  color-scheme: dark;
  --bg: #0b1220;
  --surface: #0f172a;
  --surface-muted: #111827;
  --border: #1f2937;
  --text-primary: #f3f4f6;
  --text-secondary: #cbd5e1;
  --accent: #e5e7eb;
  --accent-contrast: #0b1220;
}

/* Additional themes: warm/midnight/ocean/rose/forest/amber/slate/neon/sunset */

Theme Switcher Pattern:

"use client";

import { getUserPreferences, updatePreferences } from "@/lib/userPreferences";

export function ThemeSwitcher() {
  const [theme, setTheme] = useState(() => {
    const prefs = getUserPreferences();
    return prefs.theme;
  });

  const handleThemeChange = (newTheme: string) => {
    setTheme(newTheme);
    updatePreferences({ theme: newTheme as any });
  };

  return (
    <select value={theme} onChange={(e) => handleThemeChange(e.target.value)}>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="system">System</option>
      <option value="ocean">Ocean</option>
      {/* 8 more themes... */}
    </select>
  );
}

UI Density (Compact Mode) Pattern

Use Case

Users can opt into a denser layout (“Compact Mode”) without breaking tap targets or causing layout shift.

Implementation

  • Preference: compactMode stored in clt_preferences (src/lib/userPreferences.ts)
  • Applied to the document root: data-compact="true" / "false"
    • Initialized early (before first paint) in src/app/layout.tsx
    • Kept in sync client-side in src/components/PreferencesInitializer.tsx
  • CSS: token-based spacing and typography in src/app/globals.css (--page-px, --layout-gap, --card-padding, etc.)

Guideline: compact mode reduces whitespace, but should not reduce mobile tap targets below 44px.


Analytics Tracking Pattern

Client-Side Event Tracking

Location: src/lib/analytics/client.ts

interface ToolUsageEvent {
  toolSlug: string;
  action: "process" | "copy" | "download" | "share";
  metadata?: Record<string, any>;
}

export function trackToolUsage(
  toolSlug: string,
  metadata?: Record<string, any>
) {
  // Google Analytics 4
  if (typeof window !== "undefined" && window.gtag) {
    window.gtag("event", "tool_usage", {
      tool_name: toolSlug,
      ...metadata,
    });
  }

  // Custom analytics (optional)
  fetch("/api/v1/analytics/track", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      event: "tool_usage",
      toolSlug,
      metadata,
      timestamp: Date.now(),
    }),
  }).catch(() => {
    // Fail silently
  });
}

export function trackKeyboardShortcut(toolSlug: string, shortcut: string) {
  if (typeof window !== "undefined" && window.gtag) {
    window.gtag("event", "keyboard_shortcut", {
      tool_name: toolSlug,
      shortcut,
    });
  }
}

Error Handling Pattern

Try-Catch with User Feedback

async function handleToolProcessing(input: string) {
  try {
    // Validate input
    if (!input || input.trim().length === 0) {
      throw new Error("Input cannot be empty");
    }

    // Process
    const result = await processInput(input);

    // Track success
    trackToolUsage(TOOL_SLUG, { success: true });

    return result;
  } catch (error) {
    // Log error
    console.error(`[${TOOL_SLUG}] Processing failed:`, error);

    // Track failure
    trackToolUsage(TOOL_SLUG, {
      success: false,
      error: error instanceof Error ? error.message : "Unknown error"
    });

    // User-friendly error message
    if (error instanceof SyntaxError) {
      setToastMessage("Invalid input format");
    } else if (error instanceof Error) {
      setToastMessage(error.message);
    } else {
      setToastMessage("An unexpected error occurred");
    }

    return "";
  }
}

Pro Feature Gating Pattern


Client-Side Entitlement Check

Location: src/lib/entitlements.ts

interface UserEntitlements {
  isPro: boolean;
  apiAccess: boolean;
  batchProcessing: boolean;
  cloudSync: boolean;
  features: {
    maxLines: number;
    maxApiCalls: number;
  };
}

export async function getUserEntitlements(): Promise<UserEntitlements> {
  const { userId } = auth();

  if (!userId) {
    return {
      isPro: false,
      apiAccess: false,
      batchProcessing: false,
      cloudSync: false,
      features: {
        maxLines: 100,
        maxApiCalls: 0,
      },
    };
  }

  // Check Stripe subscription via Clerk metadata
  const user = await currentUser();
  const metadata = user?.publicMetadata as { stripePlan?: string };

  return {
    isPro: metadata?.stripePlan === "pro",
    apiAccess: metadata?.stripePlan === "pro",
    batchProcessing: metadata?.stripePlan === "pro",
    cloudSync: metadata?.stripePlan === "pro",
    features: {
      maxLines: metadata?.stripePlan === "pro" ? 5000 : 100,
      maxApiCalls: metadata?.stripePlan === "pro" ? 5000 : 0,
    },
  };
}

Usage in Component:

export function ExampleTool() {
  const [entitlements, setEntitlements] = useState<UserEntitlements | null>(null);

  useEffect(() => {
    getUserEntitlements().then(setEntitlements);
  }, []);

  const handleProcess = () => {
    if (!entitlements) return;

    const lineCount = input.split("\n").length;

    if (lineCount > entitlements.features.maxLines) {
      setToastMessage(
        `Free tier limited to ${entitlements.features.maxLines} lines. Upgrade to Pro for 5,000 lines.`
      );
      return;
    }

    // Process input...
  };

  return (
    <div>
      {/* Show upgrade prompt if needed */}
      {entitlements && !entitlements.isPro && (
        <div className="rounded-lg border border-yellow-300 bg-yellow-50 p-4">
          <p>Free tier: {entitlements.features.maxLines} lines max</p>
          <a href="/pricing" className="text-blue-600 underline">
            Upgrade to Pro →
          </a>
        </div>
      )}

      {/* Tool UI... */}
    </div>
  );
}

Summary Table

PatternUse CaseKey FilesCritical?
Tool ComponentAll toolssrc/components/tools/*.tsx✅ Yes
useUndoRedoText input toolssrc/hooks/useUndoRedo.ts✅ Yes
useToolHistoryHistory trackingsrc/hooks/useToolHistory.ts⚠️ Recommended
useShareableToolShare linkssrc/hooks/useShareableTool.ts⚠️ Recommended
Keyboard ShortcutsAll interactive toolssrc/hooks/useKeyboardShortcuts.ts✅ Yes
Share/ExportDownload featuresVarious tool components⚠️ Recommended
Magic InputHomepagesrc/lib/magicInput.ts⚠️ Optional
Workflow BuilderAutomationsrc/lib/workflowEngine.ts✅ Yes
Theme SystemGlobal stylingsrc/app/globals.css✅ Yes
UI DensityCompact modesrc/app/globals.css⚠️ Recommended
AnalyticsUsage trackingsrc/lib/analytics/client.ts⚠️ Recommended
Error HandlingAll async operationsAll components✅ Yes
Pro Feature GatingMonetizationsrc/lib/entitlements.ts✅ Yes
Mobile Options SheetReduce mobile cluttersrc/components/ToolOptionsSheet.tsx⚠️ Optional

Document Owner: Tyson K. Last Updated: December 14, 2025 Next Review: March 1, 2026

Patterns | CleanTextLab Docs