├── pnpm-workspace.yaml
├── www
├── public
│ └── images
│ │ ├── ao-domain.png
│ │ └── dubco-domain.png
├── content
│ └── docs
│ │ ├── custom-domain.mdx
│ │ ├── index.mdx
│ │ └── real-world-examples.mdx
├── postcss.config.mjs
├── app
│ ├── api
│ │ └── search
│ │ │ └── route.ts
│ ├── (home)
│ │ ├── layout.tsx
│ │ ├── components.tsx
│ │ └── page.tsx
│ ├── docs
│ │ ├── layout.tsx
│ │ ├── custom-domain
│ │ │ ├── copy-button.tsx
│ │ │ └── page.tsx
│ │ └── [[...slug]]
│ │ │ └── page.tsx
│ ├── layout.tsx
│ ├── layout.config.tsx
│ ├── providers.tsx
│ └── globals.css
├── lib
│ ├── utils.ts
│ └── source.ts
├── source.config.ts
├── components.json
├── next.config.mjs
├── tsconfig.json
├── components
│ ├── ui
│ │ ├── label.tsx
│ │ ├── textarea.tsx
│ │ ├── input.tsx
│ │ ├── checkbox.tsx
│ │ ├── button.tsx
│ │ ├── tabs.tsx
│ │ ├── card.tsx
│ │ └── form.tsx
│ └── domains
│ │ ├── actions.ts
│ │ └── client.tsx
├── package.json
└── tailwind.config.ts
├── package.json
├── kit
├── postcss.config.mjs
├── src
│ ├── lib
│ │ └── utils.tsx
│ ├── components
│ │ └── ui
│ │ │ ├── label.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── input.tsx
│ │ │ ├── button.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── card.tsx
│ │ │ └── form.tsx
│ └── domains
│ │ ├── actions.ts
│ │ └── client.tsx
├── components.json
├── tsconfig.json
├── package.json
├── globals.css
└── tailwind.config.ts
├── biome.json
├── .gitignore
├── LICENSE
└── README.md
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - kit
3 | - www
4 |
--------------------------------------------------------------------------------
/www/public/images/ao-domain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RhysSullivan/tenant-kit/HEAD/www/public/images/ao-domain.png
--------------------------------------------------------------------------------
/www/public/images/dubco-domain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/RhysSullivan/tenant-kit/HEAD/www/public/images/dubco-domain.png
--------------------------------------------------------------------------------
/www/content/docs/custom-domain.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Custom Domain
3 | description: Set up a custom domain for your tenant
4 | ---
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tenant-kit",
3 | "version": "0.1.0",
4 | "private": true,
5 | "devDependencies": {
6 | "@biomejs/biome": "1.9.4"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/kit/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/www/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/www/app/api/search/route.ts:
--------------------------------------------------------------------------------
1 | import { source } from "@/lib/source";
2 | import { createFromSource } from "fumadocs-core/search/server";
3 |
4 | export const { GET } = createFromSource(source);
5 |
--------------------------------------------------------------------------------
/www/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | export function cn(...inputs: ClassValue[]) {
4 | return twMerge(clsx(inputs));
5 | }
6 |
--------------------------------------------------------------------------------
/kit/src/lib/utils.tsx:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | export function cn(...inputs: ClassValue[]) {
4 | return twMerge(clsx(inputs));
5 | }
6 |
--------------------------------------------------------------------------------
/www/source.config.ts:
--------------------------------------------------------------------------------
1 | import { defineDocs, defineConfig } from "fumadocs-mdx/config";
2 |
3 | export const { docs, meta } = defineDocs({
4 | dir: "content/docs",
5 | });
6 |
7 | export default defineConfig();
8 |
--------------------------------------------------------------------------------
/www/content/docs/index.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Introduction
3 | description: Introduction to Tenant Kit
4 | ---
5 |
6 | These are very WIP docs, check out the [Custom Domain Component](/docs/custom-domain) for the first component to get started.
--------------------------------------------------------------------------------
/www/lib/source.ts:
--------------------------------------------------------------------------------
1 | import { docs, meta } from "@/.source";
2 | import { createMDXSource } from "fumadocs-mdx";
3 | import { loader } from "fumadocs-core/source";
4 |
5 | export const source = loader({
6 | baseUrl: "/docs",
7 | source: createMDXSource(docs, meta),
8 | });
9 |
--------------------------------------------------------------------------------
/www/app/(home)/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 | import { HomeLayout } from "fumadocs-ui/layouts/home";
3 | import { baseOptions } from "@/app/layout.config";
4 |
5 | export default function Layout({
6 | children,
7 | }: {
8 | children: ReactNode;
9 | }): React.ReactElement {
10 | return {children} ;
11 | }
12 |
--------------------------------------------------------------------------------
/kit/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "./globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/www/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "./app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/www/app/docs/layout.tsx:
--------------------------------------------------------------------------------
1 | import { DocsLayout } from "fumadocs-ui/layouts/docs";
2 | import type { ReactNode } from "react";
3 | import { baseOptions } from "@/app/layout.config";
4 | import { source } from "@/lib/source";
5 |
6 | export default function Layout({ children }: { children: ReactNode }) {
7 | return (
8 |
9 | {children}
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/www/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { createMDX } from "fumadocs-mdx/next";
2 |
3 | const withMDX = createMDX();
4 |
5 | /** @type {import('next').NextConfig} */
6 | const config = {
7 | reactStrictMode: true,
8 | skipTrailingSlashRedirect: true,
9 | rewrites: async () => [
10 | {
11 | source: "/ingest/static/:path*",
12 | destination: "https://us-assets.i.posthog.com/static/:path*",
13 | },
14 | {
15 | source: "/ingest/:path*",
16 | destination: "https://us.i.posthog.com/:path*",
17 | },
18 | ],
19 | };
20 |
21 | export default withMDX(config);
22 |
--------------------------------------------------------------------------------
/biome.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3 | "vcs": {
4 | "enabled": true,
5 | "clientKind": "git",
6 | "useIgnoreFile": true
7 | },
8 | "files": {
9 | "ignoreUnknown": false,
10 | "ignore": []
11 | },
12 | "formatter": {
13 | "enabled": true,
14 | "indentStyle": "tab"
15 | },
16 | "organizeImports": {
17 | "enabled": true
18 | },
19 | "linter": {
20 | "enabled": true,
21 | "rules": {
22 | "recommended": true
23 | }
24 | },
25 | "javascript": {
26 | "formatter": {
27 | "quoteStyle": "double"
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/kit/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./src/*"]
22 | },
23 | "types": ["node"]
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/www/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import "./globals.css";
2 | import { RootProvider } from "fumadocs-ui/provider";
3 | import { Inter } from "next/font/google";
4 | import type { ReactNode } from "react";
5 | import Providers from "./providers";
6 |
7 | const inter = Inter({
8 | subsets: ["latin"],
9 | });
10 |
11 | export default function Layout({ children }: { children: ReactNode }) {
12 | return (
13 |
14 |
15 |
16 | {children}
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/www/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "target": "ESNext",
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "allowJs": true,
7 | "skipLibCheck": true,
8 | "strict": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "esnext",
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": true,
18 | "paths": {
19 | "@/*": ["./*"],
20 | },
21 | "plugins": [
22 | {
23 | "name": "next"
24 | }
25 | ]
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/www/app/layout.config.tsx:
--------------------------------------------------------------------------------
1 | import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
2 | import { Globe } from "lucide-react";
3 |
4 | /**
5 | * Shared layout configurations
6 | *
7 | * you can configure layouts individually from:
8 | * Home Layout: app/(home)/layout.tsx
9 | * Docs Layout: app/docs/layout.tsx
10 | */
11 | export const baseOptions: BaseLayoutProps = {
12 | nav: {
13 | title: (
14 |
15 |
16 | Tenant Kit
17 |
18 | ),
19 | },
20 | githubUrl: "https://github.com/rhyssullivan/tenant-kit",
21 | links: [
22 | {
23 | text: "Documentation",
24 | url: "/docs",
25 | active: "nested-url",
26 | },
27 | ],
28 | };
29 |
--------------------------------------------------------------------------------
/kit/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/www/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
11 | );
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ));
24 | Label.displayName = LabelPrimitive.Root.displayName;
25 |
26 | export { Label };
27 |
--------------------------------------------------------------------------------
/www/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/kit/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/www/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/kit/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/www/app/providers.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { ReactNode } from "react";
3 |
4 | import posthog from "posthog-js";
5 | import { PostHogProvider } from "posthog-js/react";
6 |
7 | if (typeof window !== "undefined") {
8 | // biome-ignore lint/style/noNonNullAssertion:
9 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
10 | // biome-ignore lint/style/noNonNullAssertion:
11 | person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well
12 | api_host: "/ingest",
13 | ui_host: "https://us.i.posthog.com",
14 | });
15 | }
16 | export function CSPostHogProvider({ children }: { children: ReactNode }) {
17 | return {children} ;
18 | }
19 |
20 | export default function Providers({ children }: { children: ReactNode }) {
21 | return {children} ;
22 | }
23 |
--------------------------------------------------------------------------------
/www/app/(home)/components.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { DomainStatus } from "@/components/domains/client";
3 | import { useDomainStatus } from "@/components/domains/client";
4 | import { Card } from "@/components/ui/card";
5 |
6 | import { ExternalLink } from "lucide-react";
7 | export function DomainStatusCard({ domain }: { domain: string }) {
8 | const status = useDomainStatus(domain);
9 | return (
10 |
11 |
17 | {domain}
18 |
19 |
20 |
21 | {status?.status}
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 | .env
3 | # dependencies
4 | node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | .next
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # deps
39 | /node_modules
40 |
41 | # generated content
42 | .contentlayer
43 | .content-collections
44 | .source
45 |
46 | # test & build
47 | /coverage
48 | .next
49 | /out/
50 | /build
51 | *.tsbuildinfo
52 |
53 | # misc
54 | .DS_Store
55 | *.pem
56 | /.pnp
57 | .pnp.js
58 | npm-debug.log*
59 | yarn-debug.log*
60 | yarn-error.log*
61 |
62 | # others
63 | .env*.local
64 | .vercel
65 | next-env.d.ts
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 Rhys Sullivan
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/kit/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tenant-kit/kit",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@hookform/resolvers": "^3.9.0",
7 | "@radix-ui/react-label": "^2.1.0",
8 | "@radix-ui/react-slot": "^1.1.0",
9 | "@radix-ui/react-tabs": "^1.1.0",
10 | "bright": "^0.8.5",
11 | "class-variance-authority": "^0.7.0",
12 | "clsx": "^2.1.1",
13 | "lucide-react": "^0.400.0",
14 |
15 | "react": "19.0.0-rc-cae764ce-20241025",
16 | "react-dom": "19.0.0-rc-cae764ce-20241025",
17 | "react-hook-form": "^7.52.1",
18 | "swr": "^2.2.5",
19 | "tailwind-merge": "^2.3.0",
20 | "tailwindcss-animate": "^1.0.7",
21 | "zod": "^3.23.8"
22 | },
23 | "devDependencies": {
24 | "@types/react": "npm:types-react@rc",
25 | "@types/react-dom": "npm:types-react-dom@rc",
26 | "@types/node": "22.7.8",
27 | "eslint": "^8",
28 | "eslint-config-next": "14.2.4",
29 | "postcss": "^8",
30 | "tailwindcss": "^3.4.1",
31 | "typescript": "^5"
32 | },
33 | "overrides": {
34 | "@types/react": "npm:types-react@rc",
35 | "@types/react-dom": "npm:types-react-dom@rc"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/www/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5 | import { Check } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Checkbox = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
21 |
24 |
25 |
26 |
27 | ))
28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
29 |
30 | export { Checkbox }
31 |
--------------------------------------------------------------------------------
/www/app/docs/custom-domain/copy-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Check, Copy } from "lucide-react";
5 | import { usePostHog } from "posthog-js/react";
6 | import { useState } from "react";
7 |
8 | interface CopyButtonProps {
9 | text: string;
10 | className?: string;
11 | name: string;
12 | }
13 |
14 | export function CopyButton({ text, className = "", name }: CopyButtonProps) {
15 | const [isCopied, setIsCopied] = useState(false);
16 | const posthog = usePostHog();
17 | const copyToClipboard = async () => {
18 | try {
19 | await navigator.clipboard.writeText(text);
20 | setIsCopied(true);
21 | setTimeout(() => setIsCopied(false), 2000);
22 | posthog.capture("Copy to clipboard", {
23 | name,
24 | });
25 | } catch (err) {
26 | console.error("Failed to copy text: ", err);
27 | }
28 | };
29 |
30 | return (
31 |
37 | Copy to clipboard
38 | {isCopied ? (
39 |
40 | ) : (
41 |
42 | )}
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/www/app/docs/[[...slug]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { source } from "@/lib/source";
2 | import {
3 | DocsPage,
4 | DocsBody,
5 | DocsDescription,
6 | DocsTitle,
7 | } from "fumadocs-ui/page";
8 | import { notFound } from "next/navigation";
9 | import defaultMdxComponents from "fumadocs-ui/mdx";
10 |
11 | export default async function Page(props: {
12 | params: Promise<{ slug?: string[] }>;
13 | }) {
14 | const params = await props.params;
15 | const page = source.getPage(params.slug);
16 | if (!page) notFound();
17 |
18 | const MDX = page.data.body;
19 |
20 | return (
21 |
22 | {page.data.title}
23 | {page.data.description}
24 |
25 | {/* @ts-expect-error */}
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | export async function generateStaticParams() {
33 | return source.generateParams();
34 | }
35 |
36 | export async function generateMetadata(props: {
37 | params: Promise<{ slug?: string[] }>;
38 | }) {
39 | const params = await props.params;
40 | const page = source.getPage(params.slug);
41 | if (!page) notFound();
42 |
43 | return {
44 | title: page.data.title,
45 | description: page.data.description,
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/www/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "www",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "dev": "next dev --turbo",
8 | "start": "next start",
9 | "postinstall": "fumadocs-mdx"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.9.0",
13 | "@radix-ui/react-checkbox": "^1.1.2",
14 | "@radix-ui/react-label": "^2.1.0",
15 | "@radix-ui/react-slot": "^1.1.0",
16 | "@radix-ui/react-tabs": "^1.1.0",
17 | "@tailwindcss/typography": "^0.5.15",
18 | "@tenant-kit/kit": "workspace:*",
19 | "@vercel/firewall": "^0.1.3",
20 | "bright": "^0.8.5",
21 | "class-variance-authority": "^0.7.0",
22 | "clsx": "^2.1.1",
23 | "fumadocs-core": "14.0.2",
24 | "fumadocs-mdx": "11.0.0",
25 | "fumadocs-ui": "14.0.2",
26 | "lucide-react": "^0.400.0",
27 | "next": "15.0.0",
28 | "posthog-js": "^1.178.0",
29 | "react": "19.0.0-rc-cae764ce-20241025",
30 | "react-dom": "19.0.0-rc-cae764ce-20241025",
31 | "react-hook-form": "^7.52.1",
32 | "swr": "^2.2.5",
33 | "tailwind-merge": "^2.3.0",
34 | "tailwindcss-animate": "^1.0.7",
35 | "tailwindcss-animated": "^1.1.2",
36 | "zod": "^3.23.8"
37 | },
38 | "devDependencies": {
39 | "@types/mdx": "^2.0.13",
40 | "@types/node": "22.7.8",
41 | "@types/react": "npm:types-react@rc",
42 | "@types/react-dom": "npm:types-react-dom@rc",
43 | "autoprefixer": "^10.4.20",
44 | "postcss": "^8.4.47",
45 | "tailwindcss": "^3.4.14",
46 | "tailwindcss-animate": "^1.0.7",
47 | "typescript": "^5.6.3"
48 | },
49 | "overrides": {
50 | "@types/react": "npm:types-react@rc",
51 | "@types/react-dom": "npm:types-react-dom@rc"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/kit/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 5.9% 10%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 240 10% 3.9%;
36 | --foreground: 0 0% 98%;
37 | --card: 240 10% 3.9%;
38 | --card-foreground: 0 0% 98%;
39 | --popover: 240 10% 3.9%;
40 | --popover-foreground: 0 0% 98%;
41 | --primary: 0 0% 98%;
42 | --primary-foreground: 240 5.9% 10%;
43 | --secondary: 240 3.7% 15.9%;
44 | --secondary-foreground: 0 0% 98%;
45 | --muted: 240 3.7% 15.9%;
46 | --muted-foreground: 240 5% 64.9%;
47 | --accent: 240 3.7% 15.9%;
48 | --accent-foreground: 0 0% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 0 0% 98%;
51 | --border: 240 3.7% 15.9%;
52 | --input: 240 3.7% 15.9%;
53 | --ring: 240 4.9% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/www/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 240 10% 3.9%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 240 10% 3.9%;
13 | --primary: 240 5.9% 10%;
14 | --primary-foreground: 0 0% 98%;
15 | --secondary: 240 4.8% 95.9%;
16 | --secondary-foreground: 240 5.9% 10%;
17 | --muted: 240 4.8% 95.9%;
18 | --muted-foreground: 240 3.8% 46.1%;
19 | --accent: 240 4.8% 95.9%;
20 | --accent-foreground: 240 5.9% 10%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 0 0% 98%;
23 | --border: 240 5.9% 90%;
24 | --input: 240 5.9% 90%;
25 | --ring: 240 5.9% 10%;
26 | --radius: 0.5rem;
27 | --chart-1: 12 76% 61%;
28 | --chart-2: 173 58% 39%;
29 | --chart-3: 197 37% 24%;
30 | --chart-4: 43 74% 66%;
31 | --chart-5: 27 87% 67%;
32 | }
33 |
34 | .dark {
35 | --background: 240 10% 3.9%;
36 | --foreground: 0 0% 98%;
37 | --card: 240 10% 3.9%;
38 | --card-foreground: 0 0% 98%;
39 | --popover: 240 10% 3.9%;
40 | --popover-foreground: 0 0% 98%;
41 | --primary: 0 0% 98%;
42 | --primary-foreground: 240 5.9% 10%;
43 | --secondary: 240 3.7% 15.9%;
44 | --secondary-foreground: 0 0% 98%;
45 | --muted: 240 3.7% 15.9%;
46 | --muted-foreground: 240 5% 64.9%;
47 | --accent: 240 3.7% 15.9%;
48 | --accent-foreground: 0 0% 98%;
49 | --destructive: 0 62.8% 30.6%;
50 | --destructive-foreground: 0 0% 98%;
51 | --border: 240 3.7% 15.9%;
52 | --input: 240 3.7% 15.9%;
53 | --ring: 240 4.9% 83.9%;
54 | --chart-1: 220 70% 50%;
55 | --chart-2: 160 60% 45%;
56 | --chart-3: 30 80% 55%;
57 | --chart-4: 280 65% 60%;
58 | --chart-5: 340 75% 55%;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/www/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button";
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = "Button";
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/kit/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | },
34 | );
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean;
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button";
45 | return (
46 |
51 | );
52 | },
53 | );
54 | Button.displayName = "Button";
55 |
56 | export { Button, buttonVariants };
57 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Tenant Kit
2 |
3 | A toolkit for building multi-tenant applications. The goal of this project is to provide components, documentation, and utilities for handling custom domains, authentication, and customization across multiple tenants.
4 |
5 | This is a highly work in progress project being built in public, it will be incomplete and there will be lots of iteration.
6 |
7 | Open issues, PRs, and tweet feedback at me to make this better.
8 |
9 |
10 |
11 | ## Planned topics
12 |
13 | Domains
14 | - Custom domains i.e tenant.example.com
15 | - Subpaths i.e example.com/tenant
16 | - Using a custom domain as a handle i.e Bluesky (ATProtocol)
17 |
18 | Customization
19 | - Supporting custom HTML, CSS, and JavaScript
20 | - Adding built in support for most analytics providers
21 |
22 | Authentication
23 | - Auth across subdomains, subpaths, and custom domains
24 |
25 | Hosting
26 | - Multi tenancy on Vercel
27 | - Multi tenancy on Cloudflare
28 | - Multi tenancy on a $5 VPS
29 |
30 | ## Project Structure
31 |
32 | ```
33 | .
34 | ├── kit/ # Core components and utilities
35 | ├── www/ # Documentation and demo site
36 | ```
37 |
38 |
39 | ## Getting Started
40 |
41 | 1. Install dependencies:
42 | ```bash
43 | pnpm install
44 | ```
45 |
46 |
47 | 2. Run the development server:
48 | ```bash
49 | pnpm dev
50 | ```
51 |
52 |
53 | 3. Open [http://localhost:3000](http://localhost:3000) to view the documentation.
54 |
55 | ## Documentation
56 |
57 | The documentation site is built using [Fumadocs](https://fumadocs.vercel.app) and includes:
58 |
59 | - Component examples
60 | - API documentation
61 | - Integration guides
62 | - Deployment instructions
63 |
64 | ## Contributing
65 |
66 | 1. Fork the repository
67 | 2. Create a feature branch
68 | 3. Submit a pull request
69 |
70 | ## Tech Stack
71 |
72 | - Next.js 15.0.0
73 | - React (Canary)
74 | - TypeScript
75 | - Tailwind CSS
76 | - Radix UI
77 | - React Query
78 | - Fumadocs
79 |
80 | The project uses a monorepo structure with PNPM workspaces for package management.
81 |
--------------------------------------------------------------------------------
/www/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TabsPrimitive from "@radix-ui/react-tabs";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent };
56 |
--------------------------------------------------------------------------------
/kit/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TabsPrimitive from "@radix-ui/react-tabs";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Tabs = TabsPrimitive.Root;
9 |
10 | const TabsList = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ));
23 | TabsList.displayName = TabsPrimitive.List.displayName;
24 |
25 | const TabsTrigger = React.forwardRef<
26 | React.ElementRef,
27 | React.ComponentPropsWithoutRef
28 | >(({ className, ...props }, ref) => (
29 |
37 | ));
38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
39 |
40 | const TabsContent = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 | ));
53 | TabsContent.displayName = TabsPrimitive.Content.displayName;
54 |
55 | export { Tabs, TabsList, TabsTrigger, TabsContent };
56 |
--------------------------------------------------------------------------------
/www/content/docs/real-world-examples.mdx:
--------------------------------------------------------------------------------
1 | ---
2 | title: Real World Examples
3 | description: See how real open source projects are using multi tenancy.
4 | ---
5 |
6 | These projects are a mix of ones that use Tenant Kit and ones that don't, but all of them are multi tenanted in some way. They should
7 | be a helpful reference for seeing how multi tenancy is used in real world applications.
8 |
9 |
10 | ### Dub.co
11 |
12 |
13 | Dub.co allows users to bring their own domains for shortened links.
14 |
15 | 
16 |
17 | Check out their [source code on GitHub](https://github.com/dubinc/dub).
18 |
19 |
20 | ---
21 | ### Answer Overflow
22 |
23 | Answer Overflow allows users to host content on their own domains. Along with this, it supports cross domain auth with NextAuth.
24 |
25 | 
26 |
27 | Check out their [source code on GitHub](https://github.com/answer-overflow/answer-overflow).
28 |
29 |
30 | ---
31 | ### Papermark
32 |
33 | Papermark is the open-source DocSend alternative with built-in analytics and custom domains.
34 |
35 | Check out their [source code on GitHub](https://github.com/mfts/papermark/tree/main).
36 |
37 | ---
38 | ### Open Status
39 |
40 | Open Status an open-source synthetic monitoring platform. They allow you to host your own status pages on a custom domain.
41 |
42 | Check out their [source code on GitHub](https://github.com/openstatusHQ/openstatus).
43 |
44 | ---
45 |
46 | ### Discourse
47 |
48 | Discourse is a mature app with thousands of multi tenant users. While Tenant Kit is more focused on the TypeScript/React ecosystem,
49 | Discourse is a great reference for how to implement multi tenancy in Ruby on Rails. Along with this, they have excellent documentation
50 | for their customers to use on how to configure their domains.
51 |
52 | Check out their [source code on GitHub](https://github.com/discourse/discourse).
53 |
54 | ---
55 |
56 | Know of more projects that are open source and multi tenant or want to improve the ones listed here? [Create a pull request](https://github.com/rhyssullivan/tenant-kit)!
--------------------------------------------------------------------------------
/kit/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = "CardTitle";
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = "CardDescription";
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = "CardContent";
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = "CardFooter";
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/www/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = "CardTitle";
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = "CardDescription";
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = "CardContent";
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = "CardFooter";
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/kit/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config;
79 |
80 | export default config;
81 |
--------------------------------------------------------------------------------
/www/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config;
79 |
80 | import { createPreset } from "fumadocs-ui/tailwind-plugin";
81 | import typography from "@tailwindcss/typography";
82 | /** @type {import('tailwindcss').Config} */
83 | export default {
84 | ...config,
85 | content: [
86 | "./components/**/*.{ts,tsx}",
87 | "./app/**/*.{ts,tsx}",
88 | "./content/**/*.{md,mdx}",
89 | "./mdx-components.{ts,tsx}",
90 | "./node_modules/fumadocs-ui/dist/**/*.js",
91 | ],
92 | presets: [...config.plugins, createPreset(), typography()],
93 | };
94 |
--------------------------------------------------------------------------------
/www/app/(home)/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { CustomDomainConfigurator } from "@/components/domains/client";
3 | import { Button } from "@/components/ui/button";
4 | import { Checkbox } from "@/components/ui/checkbox";
5 | import { ExternalLinkIcon } from "lucide-react";
6 |
7 | export default async function HomePage() {
8 | return (
9 |
10 |
11 |
12 | Quickly build multi-tenant applications
13 |
14 |
15 | A collection of documentation, components, and resources for building
16 | multi-tenant applications.
17 |
18 |
19 |
20 |
21 | This site will cover the following topics
22 |
23 | {/* Domains */}
24 |
Domains
25 |
42 | {/* Customization */}
43 |
Customization
44 |
45 |
46 |
47 | Supporting custom HTML, CSS, and JavaScript
48 |
49 |
50 |
51 | Adding built in support for most analytics providers
52 |
53 |
54 | {/* Auth */}
55 |
Authentication
56 |
57 |
58 |
59 | Auth across subdomains, subpaths, and custom domains
60 |
61 |
62 | {/* Hosting */}
63 |
Hosting
64 |
65 |
66 |
67 | Multi tenancy on Vercel
68 |
69 |
70 |
71 | Multi tenancy on Cloudflare
72 |
73 |
74 |
75 | Multi tenancy on a $5 VPS
76 |
77 |
78 |
79 |
Try out the custom domain component below!
80 |
81 |
82 |
83 |
87 | Star on GitHub
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/www/app/docs/custom-domain/page.tsx:
--------------------------------------------------------------------------------
1 | import { Code } from "bright";
2 | import {
3 | DocsPage,
4 | DocsTitle,
5 | DocsDescription,
6 | DocsBody,
7 | } from "fumadocs-ui/page";
8 | import fs from "node:fs";
9 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
10 | import { CopyButton } from "./copy-button";
11 | import { CustomDomainConfigurator } from "@/components/domains/client";
12 |
13 | export default function Page() {
14 | const pwd = process.cwd();
15 | const client = fs.readFileSync("../kit/src/domains/client.tsx", "utf-8");
16 | const action = fs.readFileSync("../kit/src/domains/actions.ts", "utf-8");
17 | return (
18 |
19 | Custom Domain
20 | Set up a custom domain for your tenant
21 |
22 |
41 |
42 |
43 |
44 | Client
45 | Actions
46 |
47 |
51 |
56 |
62 |
68 |
69 |
73 |
78 |
84 |
90 |
91 |
92 |
93 |
94 |
95 | Examples
96 |
97 |
98 |
99 |
100 | Custom domain component pending txt verification
101 |
102 |
103 |
104 |
105 |
106 | Custom domain component CNAME configuration
107 |
108 |
109 |
110 |
111 |
112 | Custom domain component Apex configuration
113 |
114 |
115 |
116 |
117 |
118 | Successfully added domain
119 |
120 |
121 |
122 |
123 |
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/www/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import type * as LabelPrimitive from "@radix-ui/react-label";
5 | import { Slot } from "@radix-ui/react-slot";
6 | import {
7 | Controller,
8 | type ControllerProps,
9 | type FieldPath,
10 | type FieldValues,
11 | FormProvider,
12 | useFormContext,
13 | } from "react-hook-form";
14 |
15 | import { cn } from "@/lib/utils";
16 | import { Label } from "@/components/ui/label";
17 |
18 | const Form = FormProvider;
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath,
23 | > = {
24 | name: TName;
25 | };
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue,
29 | );
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath,
34 | >({
35 | ...props
36 | }: ControllerProps) => {
37 | return (
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | const useFormField = () => {
45 | const fieldContext = React.useContext(FormFieldContext);
46 | const itemContext = React.useContext(FormItemContext);
47 | const { getFieldState, formState } = useFormContext();
48 |
49 | const fieldState = getFieldState(fieldContext.name, formState);
50 |
51 | if (!fieldContext) {
52 | throw new Error("useFormField should be used within ");
53 | }
54 |
55 | const { id } = itemContext;
56 |
57 | return {
58 | id,
59 | name: fieldContext.name,
60 | formItemId: `${id}-form-item`,
61 | formDescriptionId: `${id}-form-item-description`,
62 | formMessageId: `${id}-form-item-message`,
63 | ...fieldState,
64 | };
65 | };
66 |
67 | type FormItemContextValue = {
68 | id: string;
69 | };
70 |
71 | const FormItemContext = React.createContext(
72 | {} as FormItemContextValue,
73 | );
74 |
75 | const FormItem = React.forwardRef<
76 | HTMLDivElement,
77 | React.HTMLAttributes
78 | >(({ className, ...props }, ref) => {
79 | const id = React.useId();
80 |
81 | return (
82 |
83 |
84 |
85 | );
86 | });
87 | FormItem.displayName = "FormItem";
88 |
89 | const FormLabel = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => {
93 | const { error, formItemId } = useFormField();
94 |
95 | return (
96 |
102 | );
103 | });
104 | FormLabel.displayName = "FormLabel";
105 |
106 | const FormControl = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ ...props }, ref) => {
110 | const { error, formItemId, formDescriptionId, formMessageId } =
111 | useFormField();
112 |
113 | return (
114 |
125 | );
126 | });
127 | FormControl.displayName = "FormControl";
128 |
129 | const FormDescription = React.forwardRef<
130 | HTMLParagraphElement,
131 | React.HTMLAttributes
132 | >(({ className, ...props }, ref) => {
133 | const { formDescriptionId } = useFormField();
134 |
135 | return (
136 |
142 | );
143 | });
144 | FormDescription.displayName = "FormDescription";
145 |
146 | const FormMessage = React.forwardRef<
147 | HTMLParagraphElement,
148 | React.HTMLAttributes
149 | >(({ className, children, ...props }, ref) => {
150 | const { error, formMessageId } = useFormField();
151 | const body = error ? String(error?.message) : children;
152 |
153 | if (!body) {
154 | return null;
155 | }
156 |
157 | return (
158 |
164 | {body}
165 |
166 | );
167 | });
168 | FormMessage.displayName = "FormMessage";
169 |
170 | export {
171 | useFormField,
172 | Form,
173 | FormItem,
174 | FormLabel,
175 | FormControl,
176 | FormDescription,
177 | FormMessage,
178 | FormField,
179 | };
180 |
--------------------------------------------------------------------------------
/kit/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import type * as LabelPrimitive from "@radix-ui/react-label";
5 | import { Slot } from "@radix-ui/react-slot";
6 | import {
7 | Controller,
8 | type ControllerProps,
9 | type FieldPath,
10 | type FieldValues,
11 | FormProvider,
12 | useFormContext,
13 | } from "react-hook-form";
14 |
15 | import { cn } from "@/lib/utils";
16 | import { Label } from "@/components/ui/label";
17 |
18 | const Form = FormProvider;
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath,
23 | > = {
24 | name: TName;
25 | };
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue,
29 | );
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath,
34 | >({
35 | ...props
36 | }: ControllerProps) => {
37 | return (
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | const useFormField = () => {
45 | const fieldContext = React.useContext(FormFieldContext);
46 | const itemContext = React.useContext(FormItemContext);
47 | const { getFieldState, formState } = useFormContext();
48 |
49 | const fieldState = getFieldState(fieldContext.name, formState);
50 |
51 | if (!fieldContext) {
52 | throw new Error("useFormField should be used within ");
53 | }
54 |
55 | const { id } = itemContext;
56 |
57 | return {
58 | id,
59 | name: fieldContext.name,
60 | formItemId: `${id}-form-item`,
61 | formDescriptionId: `${id}-form-item-description`,
62 | formMessageId: `${id}-form-item-message`,
63 | ...fieldState,
64 | };
65 | };
66 |
67 | type FormItemContextValue = {
68 | id: string;
69 | };
70 |
71 | const FormItemContext = React.createContext(
72 | {} as FormItemContextValue,
73 | );
74 |
75 | const FormItem = React.forwardRef<
76 | HTMLDivElement,
77 | React.HTMLAttributes
78 | >(({ className, ...props }, ref) => {
79 | const id = React.useId();
80 |
81 | return (
82 |
83 |
84 |
85 | );
86 | });
87 | FormItem.displayName = "FormItem";
88 |
89 | const FormLabel = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => {
93 | const { error, formItemId } = useFormField();
94 |
95 | return (
96 |
102 | );
103 | });
104 | FormLabel.displayName = "FormLabel";
105 |
106 | const FormControl = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ ...props }, ref) => {
110 | const { error, formItemId, formDescriptionId, formMessageId } =
111 | useFormField();
112 |
113 | return (
114 |
125 | );
126 | });
127 | FormControl.displayName = "FormControl";
128 |
129 | const FormDescription = React.forwardRef<
130 | HTMLParagraphElement,
131 | React.HTMLAttributes
132 | >(({ className, ...props }, ref) => {
133 | const { formDescriptionId } = useFormField();
134 |
135 | return (
136 |
142 | );
143 | });
144 | FormDescription.displayName = "FormDescription";
145 |
146 | const FormMessage = React.forwardRef<
147 | HTMLParagraphElement,
148 | React.HTMLAttributes
149 | >(({ className, children, ...props }, ref) => {
150 | const { error, formMessageId } = useFormField();
151 | const body = error ? String(error?.message) : children;
152 |
153 | if (!body) {
154 | return null;
155 | }
156 |
157 | return (
158 |
164 | {body}
165 |
166 | );
167 | });
168 | FormMessage.displayName = "FormMessage";
169 |
170 | export {
171 | useFormField,
172 | Form,
173 | FormItem,
174 | FormLabel,
175 | FormControl,
176 | FormDescription,
177 | FormMessage,
178 | FormField,
179 | };
180 |
--------------------------------------------------------------------------------
/kit/src/domains/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | // Vercel API is wrapped in a namespace to keep this file focused on addDomain and getDomainStatus
4 | namespace VercelAPI {
5 | const VERCEL_PROJECT_ID = process.env.VERCEL_PROJECT_ID;
6 | const VERCEL_TEAM_ID = process.env.VERCEL_TEAM_ID;
7 | const VERCEL_AUTH_TOKEN = process.env.VERCEL_AUTH_TOKEN;
8 |
9 | type DomainResponse = {
10 | name: string;
11 | apexName: string;
12 | projectId: string;
13 | verified: boolean;
14 | verification: {
15 | value: string;
16 | type: string;
17 | domain: string;
18 | reason: string;
19 | }[];
20 | redirect?: string;
21 | redirectStatusCode?: 307 | 301 | 302 | 308;
22 | gitBranch?: string;
23 | updatedAt?: number;
24 | createdAt?: number;
25 | };
26 |
27 | interface DomainConfigResponse {
28 | configuredBy?: ("CNAME" | "A" | "http") | null;
29 | acceptedChallenges?: ("dns-01" | "http-01")[];
30 | misconfigured: boolean;
31 | }
32 |
33 | function callVercelApi(path: string, options: RequestInit) {
34 | return fetch(
35 | `https://api.vercel.com/${path}${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`,
36 | {
37 | ...options,
38 | headers: {
39 | Authorization: `Bearer ${VERCEL_AUTH_TOKEN}`,
40 | ...options.headers,
41 | },
42 | },
43 | );
44 | }
45 |
46 | // https://vercel.com/docs/rest-api/endpoints/domains#domains
47 | export const getDomainResponse = async (
48 | domain: string,
49 | ): Promise => {
50 | return await callVercelApi(
51 | `v9/projects/${VERCEL_PROJECT_ID}/domains/${domain}`,
52 | {
53 | method: "GET",
54 | headers: {
55 | "Content-Type": "application/json",
56 | },
57 | },
58 | ).then((res) => {
59 | return res.json();
60 | });
61 | };
62 |
63 | export const addDomainToVercel = async (domain: string) => {
64 | return await callVercelApi(`v10/projects/${VERCEL_PROJECT_ID}/domains`, {
65 | method: "POST",
66 | headers: {
67 | "Content-Type": "application/json",
68 | },
69 | body: JSON.stringify({
70 | name: domain,
71 | // Optional: Redirect www. to root domain
72 | // ...(domain.startsWith("www.") && {
73 | // redirect: domain.replace("www.", ""),
74 | // }),
75 | }),
76 | }).then((res) => res.json());
77 | };
78 |
79 | export const getConfigResponse = async (
80 | domain: string,
81 | ): Promise => {
82 | return await callVercelApi(`v6/domains/${domain}/config`, {
83 | method: "GET",
84 | headers: {
85 | "Content-Type": "application/json",
86 | },
87 | }).then((res) => res.json());
88 | };
89 |
90 | export const verifyDomain = async (
91 | domain: string,
92 | ): Promise => {
93 | return await callVercelApi(
94 | `v9/projects/${VERCEL_PROJECT_ID}/domains/${domain}/verify`,
95 | {
96 | method: "POST",
97 | headers: {
98 | "Content-Type": "application/json",
99 | },
100 | },
101 | ).then((res) => res.json());
102 | };
103 | }
104 |
105 | namespace Internal {
106 | export function checkAuth() {
107 | throw new Error(
108 | "Update the checkAuth() function in the actions.ts file to check for auth before calling this function. You can disable this if you're just testing.",
109 | );
110 | }
111 | export function checkAddRateLimit() {
112 | throw new Error(
113 | "Update the checkAddRateLimit() function in the actions.ts file to check for add rate limit before calling this function. You can disable this if you're just testing.",
114 | );
115 | }
116 | export function checkGetRateLimit() {
117 | throw new Error(
118 | "Update the checkGetRateLimit() function in the actions.ts file to check for get rate limit before calling this function. You can disable this if you're just testing.",
119 | );
120 | }
121 | }
122 |
123 | export const addDomain = async (unsafeDomain: string) => {
124 | Internal.checkAuth();
125 | Internal.checkAddRateLimit();
126 |
127 | const domain = new URL(`https://${unsafeDomain}`).hostname;
128 | if (domain.includes("vercel.pub")) {
129 | return {
130 | error: "Cannot use vercel.pub subdomain as your custom domain",
131 | };
132 | }
133 |
134 | // TODO: handle case where domain is added to another project
135 | await Promise.all([
136 | VercelAPI.addDomainToVercel(domain),
137 | // Optional: add www subdomain as well and redirect to apex domain
138 | // addDomainToVercel(`www.${value}`),
139 | ]);
140 | };
141 |
142 | export async function getDomainStatus(unsafeDomain: string) {
143 | Internal.checkAuth();
144 | Internal.checkGetRateLimit();
145 |
146 | const domain = new URL(`https://${unsafeDomain}`).hostname;
147 | let status = "Valid Configuration";
148 |
149 | const [domainJson, configJson] = await Promise.all([
150 | VercelAPI.getDomainResponse(domain),
151 | VercelAPI.getConfigResponse(domain),
152 | ]);
153 |
154 | if (domainJson?.error?.code === "not_found") {
155 | // domain not found on Vercel project
156 | status = "Domain Not Found";
157 |
158 | // unknown error
159 | } else if (domainJson.error) {
160 | status = "Unknown Error";
161 |
162 | // if domain is not verified, we try to verify now
163 | } else if (!domainJson.verified) {
164 | status = "Pending Verification";
165 | const verificationJson = await VercelAPI.verifyDomain(domain);
166 |
167 | // domain was just verified
168 | if (verificationJson?.verified) {
169 | status = "Valid Configuration";
170 | }
171 | } else if (configJson.misconfigured) {
172 | status = "Invalid Configuration";
173 | } else {
174 | status = "Valid Configuration";
175 | }
176 |
177 | return {
178 | status,
179 | domainJson,
180 | };
181 | }
182 |
--------------------------------------------------------------------------------
/www/components/domains/actions.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import { unstable_checkRateLimit as checkRateLimit } from "@vercel/firewall";
5 | import { headers } from "next/headers";
6 |
7 | // Vercel API is wrapped in a namespace to keep this file focused on addDomain and getDomainStatus
8 | namespace VercelAPI {
9 | const VERCEL_PROJECT_ID = process.env.VERCEL_PROJECT_ID;
10 | const VERCEL_TEAM_ID = process.env.VERCEL_TEAM_ID;
11 | const VERCEL_AUTH_TOKEN = process.env.VERCEL_AUTH_TOKEN;
12 |
13 | type DomainResponse = {
14 | name: string;
15 | apexName: string;
16 | projectId: string;
17 | verified: boolean;
18 | verification: {
19 | value: string;
20 | type: string;
21 | domain: string;
22 | reason: string;
23 | }[];
24 | redirect?: string;
25 | redirectStatusCode?: 307 | 301 | 302 | 308;
26 | gitBranch?: string;
27 | updatedAt?: number;
28 | createdAt?: number;
29 | };
30 |
31 | interface DomainConfigResponse {
32 | configuredBy?: ("CNAME" | "A" | "http") | null;
33 | acceptedChallenges?: ("dns-01" | "http-01")[];
34 | misconfigured: boolean;
35 | }
36 |
37 | function callVercelApi(path: string, options: RequestInit) {
38 | return fetch(
39 | `https://api.vercel.com/${path}${VERCEL_TEAM_ID ? `?teamId=${VERCEL_TEAM_ID}` : ""}`,
40 | {
41 | ...options,
42 | headers: {
43 | Authorization: `Bearer ${VERCEL_AUTH_TOKEN}`,
44 | ...options.headers,
45 | },
46 | },
47 | );
48 | }
49 |
50 | // https://vercel.com/docs/rest-api/endpoints/domains#domains
51 | export const getDomainResponse = async (
52 | domain: string,
53 | ): Promise => {
54 | return await callVercelApi(
55 | `v9/projects/${VERCEL_PROJECT_ID}/domains/${domain}`,
56 | {
57 | method: "GET",
58 | headers: {
59 | "Content-Type": "application/json",
60 | },
61 | next: {
62 | revalidate: 60,
63 | },
64 | },
65 | ).then((res) => {
66 | return res.json();
67 | });
68 | };
69 |
70 | export const addDomainToVercel = async (domain: string) => {
71 | return await callVercelApi(`v10/projects/${VERCEL_PROJECT_ID}/domains`, {
72 | method: "POST",
73 | headers: {
74 | "Content-Type": "application/json",
75 | },
76 | body: JSON.stringify({
77 | name: domain,
78 | // Optional: Redirect www. to root domain
79 | // ...(domain.startsWith("www.") && {
80 | // redirect: domain.replace("www.", ""),
81 | // }),
82 | }),
83 | }).then((res) => res.json());
84 | };
85 |
86 | export const getConfigResponse = async (
87 | domain: string,
88 | ): Promise => {
89 | return await callVercelApi(`v6/domains/${domain}/config`, {
90 | method: "GET",
91 | headers: {
92 | "Content-Type": "application/json",
93 | },
94 |
95 | next: {
96 | revalidate: 60,
97 | },
98 | }).then((res) => res.json());
99 | };
100 |
101 | export const verifyDomain = async (
102 | domain: string,
103 | ): Promise => {
104 | return await callVercelApi(
105 | `v9/projects/${VERCEL_PROJECT_ID}/domains/${domain}/verify`,
106 | {
107 | method: "POST",
108 | headers: {
109 | "Content-Type": "application/json",
110 | },
111 | },
112 | ).then((res) => res.json());
113 | };
114 | }
115 |
116 | namespace Internal {
117 | export function checkAuth() {
118 | return null;
119 | }
120 | export async function checkAddRateLimit() {
121 | const { rateLimited } = await checkRateLimit("update-domain", {
122 | headers: await headers(),
123 | });
124 |
125 | if (rateLimited) {
126 | throw new Error("Rate limited");
127 | }
128 | }
129 | export async function checkGetRateLimit() {
130 | const { rateLimited } = await checkRateLimit("get-domains", {
131 | headers: await headers(),
132 | });
133 |
134 | if (rateLimited) {
135 | throw new Error("Rate limited");
136 | }
137 | }
138 | }
139 |
140 | export const addDomain = async (unsafeDomain: string) => {
141 | Internal.checkAuth();
142 | Internal.checkAddRateLimit();
143 |
144 | const domain = new URL(`https://${unsafeDomain}`).hostname;
145 | if (domain.includes("vercel.pub")) {
146 | return {
147 | error: "Cannot use vercel.pub subdomain as your custom domain",
148 | };
149 | }
150 |
151 | // TODO: handle case where domain is added to another project
152 | await Promise.all([
153 | VercelAPI.addDomainToVercel(domain),
154 | // Optional: add www subdomain as well and redirect to apex domain
155 | // addDomainToVercel(`www.${value}`),
156 | ]);
157 | revalidatePath("/");
158 | };
159 |
160 | export async function getDomainStatus(unsafeDomain: string) {
161 | Internal.checkGetRateLimit();
162 | Internal.checkAuth();
163 | const domain = new URL(`https://${unsafeDomain}`).hostname;
164 | let status = "Valid Configuration";
165 |
166 | const [domainJson, configJson] = await Promise.all([
167 | VercelAPI.getDomainResponse(domain),
168 | VercelAPI.getConfigResponse(domain),
169 | ]);
170 |
171 | if (domainJson?.error?.code === "not_found") {
172 | // domain not found on Vercel project
173 | status = "Domain Not Found";
174 |
175 | // unknown error
176 | } else if (domainJson.error) {
177 | status = "Unknown Error";
178 |
179 | // if domain is not verified, we try to verify now
180 | } else if (!domainJson.verified) {
181 | status = "Pending Verification";
182 | const verificationJson = await VercelAPI.verifyDomain(domain);
183 |
184 | // domain was just verified
185 | if (verificationJson?.verified) {
186 | status = "Valid Configuration";
187 | }
188 | } else if (configJson.misconfigured) {
189 | status = "Invalid Configuration";
190 | } else {
191 | status = "Valid Configuration";
192 | }
193 |
194 | return {
195 | status,
196 | domainJson,
197 | };
198 | }
199 |
--------------------------------------------------------------------------------
/www/components/domains/client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { cn } from "@/lib/utils";
3 | import { Input } from "@/components/ui/input";
4 |
5 | import { AlertCircle, CheckCircle2, XCircle, LoaderCircle } from "lucide-react";
6 |
7 | import { useState } from "react";
8 | import { useFormStatus } from "react-dom";
9 |
10 | import { getDomainStatus, addDomain } from "./actions";
11 | import {
12 | Card,
13 | CardContent,
14 | CardDescription,
15 | CardFooter,
16 | CardHeader,
17 | CardTitle,
18 | } from "@/components/ui/card";
19 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
20 | import { Button } from "@/components/ui/button";
21 | import useSWR from "swr";
22 |
23 | const CNAME_VALUE = `cname.${process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "vercel-dns.com"}`;
24 | const A_VALUE = "76.76.21.21";
25 |
26 | function DNSRecordDisplay({
27 | type,
28 | name,
29 | value,
30 | ttl,
31 | }: { type: string; name: string; value: string; ttl?: string }) {
32 | return (
33 |
34 |
35 |
Type
36 |
{type}
37 |
38 |
39 |
Name
40 |
{name}
41 |
42 |
43 |
Value
44 |
{value}
45 |
46 | {ttl && (
47 |
51 | )}
52 |
53 | );
54 | }
55 |
56 | export function useDomainStatus(domain: string) {
57 | const { data, isLoading } = useSWR(
58 | `domain-status-${domain}`,
59 | // Server actions aren't really meant to be used for data fetching
60 | // They are changing this in the future with server functions, when that is updated this will be too
61 | () => getDomainStatus(domain),
62 | {
63 | refreshInterval: 20000, // every 20 seconds
64 | },
65 | );
66 |
67 | return {
68 | status: data?.status,
69 | domainJson: data?.domainJson,
70 | loading: isLoading,
71 | };
72 | }
73 |
74 | const getSubdomain = (name: string, apexName: string) => {
75 | if (name === apexName) return null;
76 | return name.slice(0, name.length - apexName.length - 1);
77 | };
78 |
79 | const InlineSnippet = ({
80 | className,
81 | children,
82 | }: {
83 | className?: string;
84 | children: string;
85 | }) => {
86 | return (
87 |
93 | {children}
94 |
95 | );
96 | };
97 |
98 | function DomainConfiguration(props: { domain: string }) {
99 | const { domain } = props;
100 |
101 | const { status, domainJson } = useDomainStatus(domain);
102 |
103 | if (!status || status === "Valid Configuration" || !domainJson) return null;
104 |
105 | const subdomain = getSubdomain(domainJson.name, domainJson.apexName);
106 |
107 | const txtVerification =
108 | (status === "Pending Verification" &&
109 | domainJson.verification.find((x) => x.type === "TXT")) ||
110 | null;
111 |
112 | if (status === "Unknown Error") {
113 | return {domainJson.error.message}
;
114 | }
115 |
116 | const selectedTab = txtVerification
117 | ? "txt"
118 | : domainJson.name === domainJson.apexName
119 | ? "apex"
120 | : "subdomain";
121 |
122 | return (
123 |
124 |
125 |
126 |
134 | Domain Verification
135 |
136 |
144 | CNAME
145 |
146 |
154 | Apex
155 |
156 |
157 | {txtVerification && (
158 |
159 |
160 |
161 | Please set the following TXT record on{" "}
162 | {domainJson.apexName} to prove
163 | ownership of {domainJson.name} :
164 |
165 |
175 |
176 | Warning: if you are using this domain for another site, setting
177 | this TXT record will transfer domain ownership away from that
178 | site and break it. Please exercise caution when setting this
179 | record.
180 |
181 |
182 |
183 | )}
184 |
185 |
186 |
187 | To configure your subdomain{" "}
188 | {domainJson.name} , set the
189 | following CNAME record on your DNS provider to continue:
190 |
191 |
197 |
198 |
199 |
200 |
201 |
202 | To configure your domain{" "}
203 | {domainJson.apexName} , set the
204 | following A record on your DNS provider to continue:
205 |
206 |
207 |
208 |
209 | {selectedTab !== "txt" && (
210 |
211 |
212 | Note: for TTL, if 86400 is not
213 | available, set the highest value possible. Also, domain
214 | propagation can take up to an hour.
215 |
216 |
217 | )}
218 |
219 |
220 | );
221 | }
222 |
223 | export function DomainStatus({ domain }: { domain: string }) {
224 | const { status, loading } = useDomainStatus(domain);
225 | if (loading) {
226 | return ;
227 | }
228 | if (status === "Valid Configuration") {
229 | return (
230 |
235 | );
236 | }
237 | if (status === "Pending Verification") {
238 | return (
239 |
244 | );
245 | }
246 | if (status === "Domain Not Found") {
247 | return (
248 |
253 | );
254 | }
255 | if (status === "Invalid Configuration") {
256 | return (
257 |
262 | );
263 | }
264 | return null;
265 | }
266 |
267 | export function CustomDomainConfigurator(props: {
268 | defaultDomain?: string;
269 | }) {
270 | const [domain, setDomain] = useState(
271 | props.defaultDomain ?? null,
272 | );
273 | const { pending } = useFormStatus();
274 | return (
275 |
276 |
308 |
309 | );
310 | }
311 |
--------------------------------------------------------------------------------
/kit/src/domains/client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { cn } from "@/lib/utils";
3 | import { Input } from "@/components/ui/input";
4 |
5 | import { AlertCircle, CheckCircle2, XCircle, LoaderCircle } from "lucide-react";
6 |
7 | import { useState } from "react";
8 | import { useFormStatus } from "react-dom";
9 |
10 | import { getDomainStatus, addDomain } from "./actions";
11 | import {
12 | Card,
13 | CardContent,
14 | CardDescription,
15 | CardFooter,
16 | CardHeader,
17 | CardTitle,
18 | } from "@/components/ui/card";
19 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
20 | import { Button } from "@/components/ui/button";
21 | import useSWR from "swr";
22 |
23 | const CNAME_VALUE = `cname.${process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "vercel-dns.com"}`;
24 | const A_VALUE = "76.76.21.21";
25 |
26 | function DNSRecordDisplay({
27 | type,
28 | name,
29 | value,
30 | ttl,
31 | }: { type: string; name: string; value: string; ttl?: string }) {
32 | return (
33 |
34 |
35 |
Type
36 |
{type}
37 |
38 |
39 |
Name
40 |
{name}
41 |
42 |
43 |
Value
44 |
{value}
45 |
46 | {ttl && (
47 |
51 | )}
52 |
53 | );
54 | }
55 |
56 | export function useDomainStatus(domain: string) {
57 | const { data, isLoading } = useSWR(
58 | `domain-status-${domain}`,
59 | // Server actions aren't really meant to be used for data fetching
60 | // They are changing this in the future with server functions, when that is updated this will be too
61 | () => getDomainStatus(domain),
62 | {
63 | refreshInterval: 20000, // every 20 seconds
64 | },
65 | );
66 |
67 | return {
68 | status: data?.status,
69 | domainJson: data?.domainJson,
70 | loading: isLoading,
71 | };
72 | }
73 |
74 | const getSubdomain = (name: string, apexName: string) => {
75 | if (name === apexName) return null;
76 | return name.slice(0, name.length - apexName.length - 1);
77 | };
78 |
79 | const InlineSnippet = ({
80 | className,
81 | children,
82 | }: {
83 | className?: string;
84 | children: string;
85 | }) => {
86 | return (
87 |
93 | {children}
94 |
95 | );
96 | };
97 |
98 | function DomainConfiguration(props: { domain: string }) {
99 | const { domain } = props;
100 |
101 | const { status, domainJson } = useDomainStatus(domain);
102 |
103 | if (!status || status === "Valid Configuration" || !domainJson) return null;
104 |
105 | const subdomain = getSubdomain(domainJson.name, domainJson.apexName);
106 |
107 | const txtVerification =
108 | (status === "Pending Verification" &&
109 | domainJson.verification.find((x) => x.type === "TXT")) ||
110 | null;
111 |
112 | if (status === "Unknown Error") {
113 | return {domainJson.error.message}
;
114 | }
115 |
116 | const selectedTab = txtVerification
117 | ? "txt"
118 | : domainJson.name === domainJson.apexName
119 | ? "apex"
120 | : "subdomain";
121 |
122 | return (
123 |
124 |
125 |
126 |
134 | Domain Verification
135 |
136 |
144 | CNAME
145 |
146 |
154 | Apex
155 |
156 |
157 | {txtVerification && (
158 |
159 |
160 |
161 | Please set the following TXT record on{" "}
162 | {domainJson.apexName} to prove
163 | ownership of {domainJson.name} :
164 |
165 |
175 |
176 | Warning: if you are using this domain for another site, setting
177 | this TXT record will transfer domain ownership away from that
178 | site and break it. Please exercise caution when setting this
179 | record.
180 |
181 |
182 |
183 | )}
184 |
185 |
186 |
187 | To configure your subdomain{" "}
188 | {domainJson.name} , set the
189 | following CNAME record on your DNS provider to continue:
190 |
191 |
197 |
198 |
199 |
200 |
201 |
202 | To configure your domain{" "}
203 | {domainJson.apexName} , set the
204 | following A record on your DNS provider to continue:
205 |
206 |
207 |
208 |
209 | {selectedTab !== "txt" && (
210 |
211 |
212 | Note: for TTL, if 86400 is not
213 | available, set the highest value possible. Also, domain
214 | propagation can take up to an hour.
215 |
216 |
217 | )}
218 |
219 |
220 | );
221 | }
222 |
223 | export function DomainStatus({ domain }: { domain: string }) {
224 | const { status, loading } = useDomainStatus(domain);
225 | if (loading) {
226 | return ;
227 | }
228 | if (status === "Valid Configuration") {
229 | return (
230 |
235 | );
236 | }
237 | if (status === "Pending Verification") {
238 | return (
239 |
244 | );
245 | }
246 | if (status === "Domain Not Found") {
247 | return (
248 |
253 | );
254 | }
255 | if (status === "Invalid Configuration") {
256 | return (
257 |
262 | );
263 | }
264 | return null;
265 | }
266 |
267 | export function CustomDomainConfigurator(props: {
268 | defaultDomain?: string;
269 | }) {
270 | const [domain, setDomain] = useState(
271 | props.defaultDomain ?? null,
272 | );
273 | const { pending } = useFormStatus();
274 | return (
275 |
276 |
308 |
309 | );
310 | }
311 |
--------------------------------------------------------------------------------