Slug Validation System
Comprehensive guide to the automatic slug generation and validation system with real-time feedback
The Slug Validation System provides intelligent, user-friendly URL slug generation with real-time uniqueness validation for both team accounts and projects.
Overview
This system automatically generates URL-friendly slugs from names while allowing users to customize them independently. It includes debounced validation, visual feedback, and smart detection of manual edits.
Key Features
- Auto-generation: Slugs automatically generate from names
- Manual Override: Users can customize slugs independently
- Smart Detection: System detects when to resume auto-generation
- Real-time Validation: 1-second debounced uniqueness checking
- Visual Feedback: Loading, success, and error icons
- Server-side Validation: PostgreSQL RPC functions for accuracy
Architecture
Client-Side Components
1. Slugify Function
Location: packages/shared/src/slugify.ts
export function slugify(text: string): string {
return text
.toString()
.normalize('NFD') // Normalize unicode
.replace(/[\u0300-\u036f]/g, '') // Remove accents
.toLowerCase() // Convert to lowercase
.replace(/["']/g, '') // Remove quotes
.replace(/[^a-z0-9\s-]/g, '') // Keep only alphanumeric, spaces, hyphens
.trim() // Trim whitespace
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens
}
Rules:
- Converts to lowercase
- Removes special characters and accents
- Replaces spaces with hyphens
- Removes leading/trailing hyphens
- Handles unicode characters
Examples:
slugify("My Project!") // "my-project"
slugify("Café & Restaurant") // "cafe-restaurant"
slugify(" Multiple Spaces ") // "multiple-spaces"
slugify("São Paulo") // "sao-paulo"
2. useSlugValidation Hook
Location: packages/shared/src/hooks/use-slug-validation.ts
interface UseSlugValidationProps {
accountId?: string;
projectId?: string | null;
currentSlug: string | null;
type: 'account' | 'project';
validateFn: (slug: string, options: ValidationOptions) => Promise<boolean>;
debounceMs?: number;
}
interface SlugValidationState {
isValidating: boolean;
isUnique: boolean | null;
error: string | null;
validate: (slug: string) => void;
}
Features:
- Debounced validation (default 1000ms)
- Abort previous requests on new validation
- Skip validation for unchanged slugs
- Configurable validation function
- TypeScript support
Usage Example:
const slugValidation = useSlugValidation({
accountId: props.account.id,
currentSlug: props.account.slug,
type: 'account',
validateFn: async (slug, options) => {
return validateAccountSlug({
slug,
accountId: options.accountId,
});
},
});
Server-Side Components
1. Database RPC Functions
is_project_slug_unique
CREATE OR REPLACE FUNCTION is_project_slug_unique( p_account_id uuid, p_slug varchar, p_project_id uuid DEFAULT NULL ) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN -- Check if slug exists in the same account (excluding current project) RETURN NOT EXISTS ( SELECT 1 FROM public.projects WHERE account_id = p_account_id AND slug = p_slug AND (p_project_id IS NULL OR id != p_project_id) ); END; $$;
is_account_slug_unique
CREATE OR REPLACE FUNCTION is_account_slug_unique( p_slug varchar, p_account_id uuid DEFAULT NULL ) RETURNS boolean LANGUAGE plpgsql SECURITY DEFINER SET search_path = '' AS $$ BEGIN -- Check if slug exists globally (excluding current account) RETURN NOT EXISTS ( SELECT 1 FROM public.accounts WHERE slug = p_slug AND (p_account_id IS NULL OR id != p_account_id) ); END; $$;
2. Server Actions
validateProjectSlug
export async function validateProjectSlug(params: {
accountId?: string;
slug: string;
projectId?: string;
}) {
const client = getSupabaseServerClient();
const { data, error } = await client.rpc('is_project_slug_unique', {
p_account_id: params.accountId || undefined,
p_slug: params.slug,
p_project_id: params.projectId || undefined,
});
if (error) {
throw error;
}
return data;
}
Smart Auto-Generation Logic
The Problem
How do we auto-generate slugs from names while allowing users to manually customize them?
The Solution
Track the last auto-generated slug and only resume auto-generation when the current slug matches it.
Implementation
// Track the last auto-generated slug
const lastAutoGeneratedSlug = useRef(slugify(props.account.name));
// Watch the name field for changes
const nameValue = form.watch('name');
// Auto-generate slug from name when name changes
useEffect(() => {
if (nameValue) {
const currentSlug = form.getValues('slug');
const generatedSlug = slugify(nameValue);
// Only auto-update if current slug matches the last auto-generated slug
// This means the user hasn't manually customized it
if (currentSlug === lastAutoGeneratedSlug.current) {
form.setValue('slug', generatedSlug);
slugValidation.validate(generatedSlug);
lastAutoGeneratedSlug.current = generatedSlug;
}
}
}, [nameValue]);
Flow Diagram
User Input Flow:
┌─────────────────────────────────────────────────────────┐
│ User types name: "My Project" │
│ ↓ │
│ Slug auto-generates: "my-project" │
│ lastAutoGeneratedSlug = "my-project" │
│ ✓ Validation: Unique │
└─────────────────────────────────────────────────────────┘
Manual Edit Flow:
┌─────────────────────────────────────────────────────────┐
│ User changes slug to: "custom-slug" │
│ (lastAutoGeneratedSlug still = "my-project") │
│ ✓ Validation: Unique │
│ ↓ │
│ User changes name: "My Project v2" │
│ currentSlug ("custom-slug") != lastAutoGenerated │
│ ✗ NO auto-update (manual edit preserved) │
└─────────────────────────────────────────────────────────┘
Resume Auto-Generation:
┌─────────────────────────────────────────────────────────┐
│ User manually changes slug to: "my-project-v2" │
│ lastAutoGeneratedSlug = "my-project-v2" │
│ ↓ │
│ User changes name: "My Project V3" │
│ Generated slug: "my-project-v3" │
│ ✓ Auto-generation resumes │
└─────────────────────────────────────────────────────────┘
Visual Feedback System
State Indicators
// Border colors based on validation state
className={
slugValidation.isUnique === false
? 'border-destructive' // Red: Not unique
: slugValidation.isUnique === true
? 'border-green-500' // Green: Unique
: '' // Default: Validating or initial
}
Icons
{/* Show icon based on validation state */}
{slugValidation.isValidating && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
{slugValidation.isUnique === true && !slugValidation.isValidating && (
<CheckCircle className="h-4 w-4 text-green-500" />
)}
{slugValidation.isUnique === false && !slugValidation.isValidating && (
<AlertCircle className="h-4 w-4 text-destructive" />
)}
Error Messages
{slugValidation.isUnique === false && (
<p className="text-sm text-destructive">
This slug is already in use. Please choose a different one.
</p>
)}
Use Cases
Use Case 1: Creating with Auto-Generated Slug
Scenario: User creates a new team account
Steps:
- User enters name: "Acme Corporation"
- Slug auto-generates: "acme-corporation" ⚡
- System validates (1s debounce) 🔄
- Shows checkmark: ✓ Unique
- User submits form
- Team account created with slug "acme-corporation"
Technical Flow:
// 1. Name change triggers useEffect
nameValue = "Acme Corporation"
// 2. Generate slug
generatedSlug = slugify("Acme Corporation") // "acme-corporation"
// 3. Check if should auto-update
currentSlug === lastAutoGeneratedSlug // true (initial state)
// 4. Update form and validate
form.setValue('slug', "acme-corporation")
slugValidation.validate("acme-corporation")
lastAutoGeneratedSlug.current = "acme-corporation"
// 5. After 1s debounce, call server
validateAccountSlug({ slug: "acme-corporation" })
// Returns: true (unique)
Use Case 2: Manual Slug Customization
Scenario: User wants a shorter, custom slug
Steps:
- User enters name: "San Francisco Office 2024"
- Slug auto-generates: "san-francisco-office-2024"
- User manually changes slug to: "sf-office" ✏️
- System validates: ✓ Unique
- User changes name to: "San Francisco Office 2025"
- Slug remains: "sf-office" (manual edit preserved) 🔒
- User submits form
Technical Flow:
// 1. Initial auto-generation
nameValue = "San Francisco Office 2024"
generatedSlug = "san-francisco-office-2024"
lastAutoGeneratedSlug.current = "san-francisco-office-2024"
// 2. User manually edits slug
// (Direct input field change, doesn't trigger useEffect)
currentSlug = "sf-office"
// lastAutoGeneratedSlug.current still = "san-francisco-office-2024"
// 3. User changes name again
nameValue = "San Francisco Office 2025"
generatedSlug = "san-francisco-office-2025"
// 4. Check if should auto-update
currentSlug ("sf-office") !== lastAutoGeneratedSlug ("san-francisco-office-2024")
// FALSE - Don't auto-update, manual edit detected
// 5. Slug preserved
final slug = "sf-office" ✓
Use Case 3: Updating Only Slug
Scenario: User wants to change slug without changing name
Steps:
- Current name: "Marketing Team"
- Current slug: "marketing-team"
- User navigates to settings
- Keeps name unchanged
- Changes slug to: "mkt-team"
- System validates: ✓ Unique
- Clicks "Save Settings"
- Only slug updates, name unchanged
- Redirects to new URL:
/home/mkt-team/settings
Technical Flow:
// Form initial state
name: "Marketing Team"
slug: "marketing-team"
lastAutoGeneratedSlug.current: "marketing-team"
// User edits slug only
user changes slug to: "mkt-team"
// Name unchanged, so useEffect doesn't trigger
nameValue hasn't changed
// Validation triggers on slug change
slugValidation.validate("mkt-team")
// Returns: true (unique)
// Form submission
updateTeamAccountDetails({
name: "Marketing Team", // unchanged
slug: "mkt-team", // updated
...otherFields
})
// Server action handles slug change
if (newSlug !== oldSlug) {
revalidatePath(`/home/${newSlug}`, 'layout')
redirect(`/home/${newSlug}/settings`)
}
Best Practices
1. Debouncing
Always debounce validation to avoid excessive server calls:
const slugValidation = useSlugValidation({
debounceMs: 1000, // 1 second
// ...
});
2. Abort Controllers
Clean up pending requests to prevent race conditions:
const abortController = useRef<AbortController | null>(null);
// Abort previous request
if (abortController.current) {
abortController.current.abort();
}
abortController.current = new AbortController();
```
### 3. Server-Side Validation
Always validate on the server, even after client validation:
```typescript
export const updateTeamAccountDetails = enhanceAction(
async (params) => {
// Server-side validation
const isUnique = await validateAccountSlug({
slug: params.slug,
accountId: params.accountId,
});
if (!isUnique) {
throw new Error('Slug is not unique');
}
// Proceed with update
},
{ schema: UpdateTeamDetailsSchema }
);
```
### 4. Visual Feedback
Provide clear, immediate feedback:
- Loading spinner during validation
- Green checkmark for success
- Red X for errors
- Error messages below input
- Border color changes
### 5. Disable Actions
Prevent form submission during validation or when invalid:
```typescript
<Button disabled={ pending || slugValidation.isValidating || slugValidation.isUnique===false }>
Save Settings
</Button>
```
## Performance Considerations
### 1. Debouncing
Reduces server load by waiting for user to stop typing:
- Default: 1000ms (1 second)
- Adjustable based on use case
- Balances UX and performance
### 2. Request Cancellation
Aborts outdated validation requests:
- Prevents race conditions
- Reduces unnecessary server load
- Improves response accuracy
### 3. Caching
Client-side state management:
- useRef for last auto-generated slug (no re-renders)
- useState for validation state (re-renders when needed)
- Minimal re-render impact
## Testing
### Unit Tests
```typescript
describe('slugify', () => {
it('converts to lowercase', () => {
expect(slugify('MyProject')).toBe('myproject');
});
it('replaces spaces with hyphens', () => {
expect(slugify('My Project')).toBe('my-project');
});
it('removes special characters', () => {
expect(slugify('My Project!')).toBe('my-project');
});
it('removes accents', () => {
expect(slugify('Café')).toBe('cafe');
});
});
```
### Integration Tests
```typescript
describe('useSlugValidation', () => {
it('validates unique slugs', async () => {
const { result } = renderHook(() => useSlugValidation({
type: 'project',
validateFn: async () => true,
}));
act(() => {
result.current.validate('my-project');
});
await waitFor(() => {
expect(result.current.isUnique).toBe(true);
});
});
});
```
## Related Documentation
- [Projects Feature](./projects-overview.mdoc)
- [Team Accounts](./team-accounts-overview.mdoc)
- [Form Validation](./form-validation.mdoc)