Repository: vercel/platforms Branch: main Commit: bd3cd5c26e5f Files: 27 Total size: 46.4 KB Directory structure: gitextract_pyn8rlj4/ ├── .gitignore ├── README.md ├── app/ │ ├── actions.ts │ ├── admin/ │ │ ├── dashboard.tsx │ │ └── page.tsx │ ├── globals.css │ ├── layout.tsx │ ├── not-found.tsx │ ├── page.tsx │ ├── s/ │ │ └── [subdomain]/ │ │ └── page.tsx │ └── subdomain-form.tsx ├── components/ │ └── ui/ │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── emoji-picker.tsx │ ├── input.tsx │ ├── label.tsx │ └── popover.tsx ├── components.json ├── lib/ │ ├── redis.ts │ ├── subdomains.ts │ └── utils.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: README.md ================================================ # Next.js Multi-Tenant Example A production-ready example of a multi-tenant application built with Next.js 15, featuring custom subdomains for each tenant. ## Features - ✅ Custom subdomain routing with Next.js middleware - ✅ Tenant-specific content and pages - ✅ Shared components and layouts across tenants - ✅ Redis for tenant data storage - ✅ Admin interface for managing tenants - ✅ Emoji support for tenant branding - ✅ Support for local development with subdomains - ✅ Compatible with Vercel preview deployments ## Tech Stack - [Next.js 15](https://nextjs.org/) with App Router - [React 19](https://react.dev/) - [Upstash Redis](https://upstash.com/) for data storage - [Tailwind 4](https://tailwindcss.com/) for styling - [shadcn/ui](https://ui.shadcn.com/) for the design system ## Getting Started ### Prerequisites - Node.js 18.17.0 or later - pnpm (recommended) or npm/yarn - Upstash Redis account (for production) ### Installation 1. Clone the repository: ```bash git clone https://github.com/vercel/platforms.git cd platforms ``` 2. Install dependencies: ```bash pnpm install ``` 3. Set up environment variables: Create a `.env.local` file in the root directory with: ``` KV_REST_API_URL=your_redis_url KV_REST_API_TOKEN=your_redis_token ``` 4. Start the development server: ```bash pnpm dev ``` 5. Access the application: - Main site: http://localhost:3000 - Admin panel: http://localhost:3000/admin - Tenants: http://[tenant-name].localhost:3000 ## Multi-Tenant Architecture This application demonstrates a subdomain-based multi-tenant architecture where: - Each tenant gets their own subdomain (`tenant.yourdomain.com`) - The middleware handles routing requests to the correct tenant - Tenant data is stored in Redis using a `subdomain:{name}` key pattern - The main domain hosts the landing page and admin interface - Subdomains are dynamically mapped to tenant-specific content The middleware (`middleware.ts`) intelligently detects subdomains across various environments (local development, production, and Vercel preview deployments). ## Deployment This application is designed to be deployed on Vercel. To deploy: 1. Push your repository to GitHub 2. Connect your repository to Vercel 3. Configure environment variables 4. Deploy For custom domains, make sure to: 1. Add your root domain to Vercel 2. Set up a wildcard DNS record (`*.yourdomain.com`) on Vercel ================================================ FILE: app/actions.ts ================================================ 'use server'; import { redis } from '@/lib/redis'; import { isValidIcon } from '@/lib/subdomains'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; import { rootDomain, protocol } from '@/lib/utils'; export async function createSubdomainAction( prevState: any, formData: FormData ) { const subdomain = formData.get('subdomain') as string; const icon = formData.get('icon') as string; if (!subdomain || !icon) { return { success: false, error: 'Subdomain and icon are required' }; } if (!isValidIcon(icon)) { return { subdomain, icon, success: false, error: 'Please enter a valid emoji (maximum 10 characters)' }; } const sanitizedSubdomain = subdomain.toLowerCase().replace(/[^a-z0-9-]/g, ''); if (sanitizedSubdomain !== subdomain) { return { subdomain, icon, success: false, error: 'Subdomain can only have lowercase letters, numbers, and hyphens. Please try again.' }; } const subdomainAlreadyExists = await redis.get( `subdomain:${sanitizedSubdomain}` ); if (subdomainAlreadyExists) { return { subdomain, icon, success: false, error: 'This subdomain is already taken' }; } await redis.set(`subdomain:${sanitizedSubdomain}`, { emoji: icon, createdAt: Date.now() }); redirect(`${protocol}://${sanitizedSubdomain}.${rootDomain}`); } export async function deleteSubdomainAction( prevState: any, formData: FormData ) { const subdomain = formData.get('subdomain'); await redis.del(`subdomain:${subdomain}`); revalidatePath('/admin'); return { success: 'Domain deleted successfully' }; } ================================================ FILE: app/admin/dashboard.tsx ================================================ 'use client'; import { useActionState } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Trash2, Loader2 } from 'lucide-react'; import Link from 'next/link'; import { deleteSubdomainAction } from '@/app/actions'; import { rootDomain, protocol } from '@/lib/utils'; type Tenant = { subdomain: string; emoji: string; createdAt: number; }; type DeleteState = { error?: string; success?: string; }; function DashboardHeader() { // TODO: You can add authentication here with your preferred auth provider return (

Subdomain Management

{rootDomain}
); } function TenantGrid({ tenants, action, isPending }: { tenants: Tenant[]; action: (formData: FormData) => void; isPending: boolean; }) { if (tenants.length === 0) { return (

No subdomains have been created yet.

); } return (
{tenants.map((tenant) => (
{tenant.subdomain}
{tenant.emoji}
Created: {new Date(tenant.createdAt).toLocaleDateString()}
Visit subdomain →
))}
); } export function AdminDashboard({ tenants }: { tenants: Tenant[] }) { const [state, action, isPending] = useActionState( deleteSubdomainAction, {} ); return (
{state.error && (
{state.error}
)} {state.success && (
{state.success}
)}
); } ================================================ FILE: app/admin/page.tsx ================================================ import { getAllSubdomains } from '@/lib/subdomains'; import type { Metadata } from 'next'; import { AdminDashboard } from './dashboard'; import { rootDomain } from '@/lib/utils'; export const metadata: Metadata = { title: `Admin Dashboard | ${rootDomain}`, description: `Manage subdomains for ${rootDomain}` }; export default async function AdminPage() { // TODO: You can add authentication here with your preferred auth provider const tenants = await getAllSubdomains(); return (
); } ================================================ FILE: app/globals.css ================================================ @import "tailwindcss"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } :root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); --popover-foreground: oklch(0.141 0.005 285.823); --primary: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); --muted-foreground: oklch(0.552 0.016 285.938); --accent: oklch(0.967 0.001 286.375); --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32); --ring: oklch(0.705 0.015 286.067); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-primary: oklch(0.21 0.006 285.885); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.705 0.015 286.067); } .dark { --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.92 0.004 286.32); --primary-foreground: oklch(0.21 0.006 285.885); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.274 0.006 286.033); --muted-foreground: oklch(0.705 0.015 286.067); --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.552 0.016 285.938); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.552 0.016 285.938); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } .emoji-picker-container .epr-body::-webkit-scrollbar { width: 8px; } .emoji-picker-container .epr-body::-webkit-scrollbar-track { background: hsl(var(--background)); } .emoji-picker-container .epr-body::-webkit-scrollbar-thumb { background-color: hsl(var(--muted)); border-radius: 20px; } .emoji-picker-container .epr-body::-webkit-scrollbar-thumb:hover { background-color: hsl(var(--muted-foreground) / 0.5); } .emoji-picker-container .epr-category-nav { padding: 8px 0; } .emoji-picker-container .epr-header { border-bottom: 1px solid hsl(var(--border)); } .emoji-picker-container .epr-emoji-category-label { background-color: hsl(var(--background)); font-size: 0.875rem; color: hsl(var(--muted-foreground)); padding: 4px 8px; } .emoji-picker-container .epr-search { margin: 8px; border-radius: var(--radius); border: 1px solid hsl(var(--input)); background-color: hsl(var(--background)); } .emoji-picker-container .epr-search input { border-radius: var(--radius); background-color: transparent; color: hsl(var(--foreground)); } .emoji-picker-container .epr-emoji-category-content { padding: 4px; } .emoji-picker-container .epr-body { padding: 0; } .emoji-picker-container .epr-skin-tones { border-radius: var(--radius); } .emoji-picker-container button.epr-emoji { border-radius: var(--radius); } .emoji-picker-container button.epr-emoji:hover { background-color: hsl(var(--accent)); } .emoji-picker-container .epr-category-nav button { opacity: 0.5; } .emoji-picker-container .epr-category-nav button.active { opacity: 1; } .emoji-picker-container .epr-category-nav button:hover { opacity: 0.8; } ================================================ FILE: app/layout.tsx ================================================ import type { Metadata } from 'next'; import { Geist } from 'next/font/google'; import { SpeedInsights } from '@vercel/speed-insights/next'; import './globals.css'; const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] }); export const metadata: Metadata = { title: 'Platforms Starter Kit', description: 'Next.js template for building a multi-tenant SaaS.' }; export default function RootLayout({ children }: Readonly<{ children: React.ReactNode; }>) { return ( {children} ); } ================================================ FILE: app/not-found.tsx ================================================ 'use client'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { usePathname } from 'next/navigation'; import { rootDomain, protocol } from '@/lib/utils'; export default function NotFound() { const [subdomain, setSubdomain] = useState(null); const pathname = usePathname(); useEffect(() => { // Extract subdomain from URL if we're on a subdomain page if (pathname?.startsWith('/subdomain/')) { const extractedSubdomain = pathname.split('/')[2]; if (extractedSubdomain) { setSubdomain(extractedSubdomain); } } else { // Try to extract from hostname for direct subdomain access const hostname = window.location.hostname; if (hostname.includes(`.${rootDomain.split(':')[0]}`)) { const extractedSubdomain = hostname.split('.')[0]; setSubdomain(extractedSubdomain); } } }, [pathname]); return (

{subdomain ? ( <> {subdomain}.{rootDomain}{' '} doesn't exist ) : ( 'Subdomain Not Found' )}

This subdomain hasn't been created yet.

{subdomain ? `Create ${subdomain}` : `Go to ${rootDomain}`}
); } ================================================ FILE: app/page.tsx ================================================ import Link from 'next/link'; import { SubdomainForm } from './subdomain-form'; import { rootDomain } from '@/lib/utils'; export default async function HomePage() { return (
Admin

{rootDomain}

Create your own subdomain with a custom emoji

); } ================================================ FILE: app/s/[subdomain]/page.tsx ================================================ import Link from 'next/link'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { getSubdomainData } from '@/lib/subdomains'; import { protocol, rootDomain } from '@/lib/utils'; export async function generateMetadata({ params }: { params: Promise<{ subdomain: string }>; }): Promise { const { subdomain } = await params; const subdomainData = await getSubdomainData(subdomain); if (!subdomainData) { return { title: rootDomain }; } return { title: `${subdomain}.${rootDomain}`, description: `Subdomain page for ${subdomain}.${rootDomain}` }; } export default async function SubdomainPage({ params }: { params: Promise<{ subdomain: string }>; }) { const { subdomain } = await params; const subdomainData = await getSubdomainData(subdomain); if (!subdomainData) { notFound(); } return (
{rootDomain}
{subdomainData.emoji}

Welcome to {subdomain}.{rootDomain}

This is your custom subdomain page

); } ================================================ FILE: app/subdomain-form.tsx ================================================ 'use client'; import type React from 'react'; import { useState } from 'react'; import { useActionState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Smile } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { EmojiPicker, EmojiPickerContent, EmojiPickerSearch, EmojiPickerFooter } from '@/components/ui/emoji-picker'; import { createSubdomainAction } from '@/app/actions'; import { rootDomain } from '@/lib/utils'; type CreateState = { error?: string; success?: boolean; subdomain?: string; icon?: string; }; function SubdomainInput({ defaultValue }: { defaultValue?: string }) { return (
.{rootDomain}
); } function IconPicker({ icon, setIcon, defaultValue }: { icon: string; setIcon: (icon: string) => void; defaultValue?: string; }) { const [isPickerOpen, setIsPickerOpen] = useState(false); const handleEmojiSelect = ({ emoji }: { emoji: string }) => { setIcon(emoji); setIsPickerOpen(false); }; return (
{icon ? ( {icon} ) : ( No icon selected )}

Select an emoji to represent your subdomain

); } export function SubdomainForm() { const [icon, setIcon] = useState(''); const [state, action, isPending] = useActionState( createSubdomainAction, {} ); return (
{state?.error && (
{state.error}
)} ); } ================================================ FILE: components/ui/button.tsx ================================================ import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, } ) function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean }) { const Comp = asChild ? Slot : "button" return ( ) } export { Button, buttonVariants } ================================================ FILE: components/ui/card.tsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" function Card({ className, ...props }: React.ComponentProps<"div">) { return (
) } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
) } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
) } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return (
) } function CardAction({ className, ...props }: React.ComponentProps<"div">) { return (
) } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
) } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
) } export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, } ================================================ FILE: components/ui/dialog.tsx ================================================ "use client" import * as React from "react" import * as DialogPrimitive from "@radix-ui/react-dialog" import { XIcon } from "lucide-react" import { cn } from "@/lib/utils" function Dialog({ ...props }: React.ComponentProps) { return } function DialogTrigger({ ...props }: React.ComponentProps) { return } function DialogPortal({ ...props }: React.ComponentProps) { return } function DialogClose({ ...props }: React.ComponentProps) { return } function DialogOverlay({ className, ...props }: React.ComponentProps) { return ( ) } function DialogContent({ className, children, ...props }: React.ComponentProps) { return ( {children} Close ) } function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
) } function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return (
) } function DialogTitle({ className, ...props }: React.ComponentProps) { return ( ) } function DialogDescription({ className, ...props }: React.ComponentProps) { return ( ) } export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, } ================================================ FILE: components/ui/emoji-picker.tsx ================================================ "use client"; import { type EmojiPickerListCategoryHeaderProps, type EmojiPickerListEmojiProps, type EmojiPickerListRowProps, EmojiPicker as EmojiPickerPrimitive, } from "frimousse"; import { LoaderIcon, SearchIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "@/lib/utils"; function EmojiPicker({ className, ...props }: React.ComponentProps) { return ( ); } function EmojiPickerSearch({ className, ...props }: React.ComponentProps) { return (
); } function EmojiPickerRow({ children, ...props }: EmojiPickerListRowProps) { return (
{children}
); } function EmojiPickerEmoji({ emoji, className, ...props }: EmojiPickerListEmojiProps) { return ( ); } function EmojiPickerCategoryHeader({ category, ...props }: EmojiPickerListCategoryHeaderProps) { return (
{category.label}
); } function EmojiPickerContent({ className, ...props }: React.ComponentProps) { return ( No emoji found. ); } function EmojiPickerFooter({ className, ...props }: React.ComponentProps<"div">) { return (
{({ emoji }) => emoji ? ( <>
{emoji.emoji}
{emoji.label} ) : ( Select an emoji… ) }
); } export { EmojiPicker, EmojiPickerSearch, EmojiPickerContent, EmojiPickerFooter, }; ================================================ FILE: components/ui/input.tsx ================================================ import * as React from "react" import { cn } from "@/lib/utils" function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( ) } export { Input } ================================================ FILE: components/ui/label.tsx ================================================ "use client" import * as React from "react" import * as LabelPrimitive from "@radix-ui/react-label" import { cn } from "@/lib/utils" function Label({ className, ...props }: React.ComponentProps) { return ( ) } export { Label } ================================================ FILE: components/ui/popover.tsx ================================================ "use client" import * as React from "react" import * as PopoverPrimitive from "@radix-ui/react-popover" import { cn } from "@/lib/utils" function Popover({ ...props }: React.ComponentProps) { return } function PopoverTrigger({ ...props }: React.ComponentProps) { return } function PopoverContent({ className, align = "center", sideOffset = 4, ...props }: React.ComponentProps) { return ( ) } function PopoverAnchor({ ...props }: React.ComponentProps) { return } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "app/globals.css", "baseColor": "zinc", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: lib/redis.ts ================================================ import { Redis } from '@upstash/redis'; export const redis = new Redis({ url: process.env.KV_REST_API_URL, token: process.env.KV_REST_API_TOKEN }); ================================================ FILE: lib/subdomains.ts ================================================ import { redis } from '@/lib/redis'; export function isValidIcon(str: string) { if (str.length > 10) { return false; } try { // Primary validation: Check if the string contains at least one emoji character // This regex pattern matches most emoji Unicode ranges const emojiPattern = /[\p{Emoji}]/u; if (emojiPattern.test(str)) { return true; } } catch (error) { // If the regex fails (e.g., in environments that don't support Unicode property escapes), // fall back to a simpler validation console.warn( 'Emoji regex validation failed, using fallback validation', error ); } // Fallback validation: Check if the string is within a reasonable length // This is less secure but better than no validation return str.length >= 1 && str.length <= 10; } type SubdomainData = { emoji: string; createdAt: number; }; export async function getSubdomainData(subdomain: string) { const sanitizedSubdomain = subdomain.toLowerCase().replace(/[^a-z0-9-]/g, ''); const data = await redis.get( `subdomain:${sanitizedSubdomain}` ); return data; } export async function getAllSubdomains() { const keys = await redis.keys('subdomain:*'); if (!keys.length) { return []; } const values = await redis.mget(...keys); return keys.map((key, index) => { const subdomain = key.replace('subdomain:', ''); const data = values[index]; return { subdomain, emoji: data?.emoji || '❓', createdAt: data?.createdAt || Date.now() }; }); } ================================================ FILE: lib/utils.ts ================================================ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; export const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'localhost:3000'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } ================================================ FILE: middleware.ts ================================================ import { type NextRequest, NextResponse } from 'next/server'; import { rootDomain } from '@/lib/utils'; function extractSubdomain(request: NextRequest): string | null { const url = request.url; const host = request.headers.get('host') || ''; const hostname = host.split(':')[0]; // Local development environment if (url.includes('localhost') || url.includes('127.0.0.1')) { // Try to extract subdomain from the full URL const fullUrlMatch = url.match(/http:\/\/([^.]+)\.localhost/); if (fullUrlMatch && fullUrlMatch[1]) { return fullUrlMatch[1]; } // Fallback to host header approach if (hostname.includes('.localhost')) { return hostname.split('.')[0]; } return null; } // Production environment const rootDomainFormatted = rootDomain.split(':')[0]; // Handle preview deployment URLs (tenant---branch-name.vercel.app) if (hostname.includes('---') && hostname.endsWith('.vercel.app')) { const parts = hostname.split('---'); return parts.length > 0 ? parts[0] : null; } // Regular subdomain detection const isSubdomain = hostname !== rootDomainFormatted && hostname !== `www.${rootDomainFormatted}` && hostname.endsWith(`.${rootDomainFormatted}`); return isSubdomain ? hostname.replace(`.${rootDomainFormatted}`, '') : null; } export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const subdomain = extractSubdomain(request); if (subdomain) { // Block access to admin page from subdomains if (pathname.startsWith('/admin')) { return NextResponse.redirect(new URL('/', request.url)); } // For the root path on a subdomain, rewrite to the subdomain page if (pathname === '/') { return NextResponse.rewrite(new URL(`/s/${subdomain}`, request.url)); } } // On the root domain, allow normal access return NextResponse.next(); } export const config = { matcher: [ /* * Match all paths except for: * 1. /api routes * 2. /_next (Next.js internals) * 3. all root files inside /public (e.g. /favicon.ico) */ '/((?!api|_next|[\\w-]+\\.\\w+).*)' ] }; ================================================ FILE: next.config.ts ================================================ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ experimental: { // Enable experimental features if needed }, // Ensure proper handling of Vercel Analytics and Speed Insights // headers: async () => { // return [ // { // source: '/_vercel/speed-insights/script.js', // headers: [ // { // key: 'Cache-Control', // value: 'public, max-age=31536000, immutable', // }, // ], // }, // ]; // }, }; export default nextConfig; ================================================ FILE: package.json ================================================ { "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.13", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-popover": "^1.1.13", "@radix-ui/react-slot": "^1.2.2", "@upstash/redis": "^1.34.9", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "frimousse": "^0.2.0", "lucide-react": "^0.510.0", "next": "^15.3.6", "react": "^19.1.0", "react-dom": "^19.1.0", "tailwind-merge": "^3.3.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.6", "@types/node": "^22.15.17", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "tailwindcss": "^4.1.6", "tw-animate-css": "^1.2.9", "typescript": "^5.8.3" }, "packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184" } ================================================ FILE: postcss.config.mjs ================================================ const config = { plugins: ["@tailwindcss/postcss"], }; export default config; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] }