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()}
))}
);
}
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 (
);
}
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 (
);
}
================================================
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"]
}