Custom Domain
Set up a custom domain for your tenant
Default custom domain componentThis code is an updated version of the Platforms project from Vercel, meant to be a more drop in style using shadcn & server actions
"use client";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { AlertCircle, CheckCircle2, XCircle, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { useFormStatus } from "react-dom";
import { getDomainStatus, addDomain } from "./actions";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
const CNAME_VALUE = `cname.${process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "vercel-dns.com"}`;
const A_VALUE = "76.76.21.21";
function DNSRecordDisplay({
type,
name,
value,
ttl,
}: { type: string; name: string; value: string; ttl?: string }) {
return (
<div className="flex items-center justify-start space-x-10 overflow-x-scroll max-w-[80vw] md:max-w-full bg-background p-4 rounded-md border-2 font-mono">
<div>
<p className="text-sm text-muted-foreground">Type</p>
<p className="mt-2 text-sm">{type}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="mt-2 text-sm">{name}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Value</p>
<p className="mt-2 text-sm">{value}</p>
</div>
{ttl && (
<div>
<p className="text-sm text-muted-foreground">TTL</p>
<p className="mt-2 text-sm">{ttl}</p>
</div>
)}
</div>
);
}
export function useDomainStatus(domain: string) {
const { data, isLoading } = useSWR(
`domain-status-${domain}`,
// Server actions aren't really meant to be used for data fetching
// They are changing this in the future with server functions, when that is updated this will be too
() => getDomainStatus(domain),
{
refreshInterval: 20000, // every 20 seconds
},
);
return {
status: data?.status,
domainJson: data?.domainJson,
loading: isLoading,
};
}
const getSubdomain = (name: string, apexName: string) => {
if (name === apexName) return null;
return name.slice(0, name.length - apexName.length - 1);
};
const InlineSnippet = ({
className,
children,
}: {
className?: string;
children: string;
}) => {
return (
<span
className={cn(
"inline-block rounded-md px-1 py-0.5 font-mono bg-primary-foreground",
className,
)}
>
{children}
</span>
);
};
function DomainConfiguration(props: { domain: string }) {
const { domain } = props;
const { status, domainJson } = useDomainStatus(domain);
if (!status || status === "Valid Configuration" || !domainJson) return null;
const subdomain = getSubdomain(domainJson.name, domainJson.apexName);
const txtVerification =
(status === "Pending Verification" &&
domainJson.verification.find((x) => x.type === "TXT")) ||
null;
if (status === "Unknown Error") {
return <p className="mb-5 text-sm">{domainJson.error.message}</p>;
}
const selectedTab = txtVerification
? "txt"
: domainJson.name === domainJson.apexName
? "apex"
: "subdomain";
return (
<CardFooter className="border-t-2 border-muted flex justify-start flex-grow pt-4">
<Tabs value={selectedTab} className="w-full">
<TabsList className="bg-background border-b-2 rounded-none border-b-muted w-full justify-start ">
<TabsTrigger
value="txt"
className={cn(
"bg-background p-2 hidden text-muted-foreground rounded-none border-b-muted",
selectedTab === "txt" &&
"border-b-primary block border-b-2 text-primary",
)}
>
Domain Verification
</TabsTrigger>
<TabsTrigger
value="subdomain"
className={cn(
"bg-background p-2 text-muted-foreground hidden rounded-none border-b-muted",
selectedTab === "subdomain" &&
"border-b-primary block border-b-2 text-primary",
)}
>
CNAME
</TabsTrigger>
<TabsTrigger
value="apex"
className={cn(
"bg-background hidden p-2 text-muted-foreground rounded-none border-b-muted",
selectedTab === "apex" &&
"border-b-primary block border-b-2 text-primary",
)}
>
Apex
</TabsTrigger>
</TabsList>
{txtVerification && (
<TabsContent value="txt">
<div className="flex flex-col space-y-4 pt-4">
<p className="text-sm text-muted-foreground ">
Please set the following TXT record on{" "}
<InlineSnippet>{domainJson.apexName}</InlineSnippet> to prove
ownership of <InlineSnippet>{domainJson.name}</InlineSnippet>:
</p>
<DNSRecordDisplay
name={txtVerification.domain.slice(
0,
txtVerification.domain.length -
domainJson.apexName.length -
1,
)}
type={txtVerification.type}
value={txtVerification.value}
/>
<p className="text-sm text-muted-foreground mt-4">
Warning: if you are using this domain for another site, setting
this TXT record will transfer domain ownership away from that
site and break it. Please exercise caution when setting this
record.
</p>
</div>
</TabsContent>
)}
<TabsContent value="subdomain">
<div className="flex flex-col gap-4 pt-4">
<span className="text-sm">
To configure your subdomain{" "}
<InlineSnippet>{domainJson.name}</InlineSnippet>, set the
following CNAME record on your DNS provider to continue:
</span>
<DNSRecordDisplay
type={"CNAME"}
name={subdomain ?? "www"}
value={CNAME_VALUE}
ttl="86400"
/>
</div>
</TabsContent>
<TabsContent value="apex">
<div className="flex flex-col gap-4 pt-4">
<span className="text-sm">
To configure your domain{" "}
<InlineSnippet>{domainJson.apexName}</InlineSnippet>, set the
following A record on your DNS provider to continue:
</span>
<DNSRecordDisplay type="A" name="@" value={A_VALUE} ttl="86400" />
</div>
</TabsContent>
{selectedTab !== "txt" && (
<div className="my-3 text-left">
<p className="mt-5 text-sm dark:text-white">
Note: for TTL, if <InlineSnippet>86400</InlineSnippet> is not
available, set the highest value possible. Also, domain
propagation can take up to an hour.
</p>
</div>
)}
</Tabs>
</CardFooter>
);
}
export function DomainStatus({ domain }: { domain: string }) {
const { status, loading } = useDomainStatus(domain);
if (loading) {
return <LoaderCircle className="dark:text-white text-black animate-spin" />;
}
if (status === "Valid Configuration") {
return (
<CheckCircle2
fill="#2563EB"
stroke="currentColor"
className="text-white dark:text-white"
/>
);
}
if (status === "Pending Verification") {
return (
<AlertCircle
fill="#FBBF24"
stroke="currentColor"
className="text-white dark:text-black"
/>
);
}
if (status === "Domain Not Found") {
return (
<XCircle
fill="#DC2626"
stroke="currentColor"
className="text-white dark:text-black"
/>
);
}
if (status === "Invalid Configuration") {
return (
<XCircle
fill="#DC2626"
stroke="currentColor"
className="text-white dark:text-black"
/>
);
}
return null;
}
export function CustomDomainConfigurator(props: {
defaultDomain?: string;
}) {
const [domain, setDomain] = useState<string | null>(
props.defaultDomain ?? null,
);
const { pending } = useFormStatus();
return (
<Card className="flex flex-col space-y-6">
<form
onSubmit={async (event) => {
event.preventDefault(); // Prevent form from reloading the page
const data = new FormData(event.currentTarget);
const domain = data.get("customDomain") as string;
setDomain(domain);
await addDomain(domain);
}}
>
<CardHeader>
<CardTitle className="text-lg font-semibold">Custom Domain</CardTitle>
<CardDescription>The custom domain for your site.</CardDescription>
</CardHeader>
<CardContent className="relative bg-background flex flex-row items-center justify-between w-full">
<Input
type="text"
name="customDomain"
placeholder={"example.com"}
maxLength={64}
className="max-w-sm bg-background"
defaultValue={props.defaultDomain}
/>
<div className="flex items-center space-x-2">
{domain && <DomainStatus domain={domain} />}
<Button type="submit" variant="outline">
{pending ? <LoaderCircle className="animate-spin" /> : "Save"}
</Button>
</div>
</CardContent>
{domain && <DomainConfiguration domain={domain} />}
</form>
</Card>
);
}
"use client";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { AlertCircle, CheckCircle2, XCircle, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { useFormStatus } from "react-dom";
import { getDomainStatus, addDomain } from "./actions";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import useSWR from "swr";
const CNAME_VALUE = `cname.${process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "vercel-dns.com"}`;
const A_VALUE = "76.76.21.21";
function DNSRecordDisplay({
type,
name,
value,
ttl,
}: { type: string; name: string; value: string; ttl?: string }) {
return (
<div className="flex items-center justify-start space-x-10 overflow-x-scroll max-w-[80vw] md:max-w-full bg-background p-4 rounded-md border-2 font-mono">
<div>
<p className="text-sm text-muted-foreground">Type</p>
<p className="mt-2 text-sm">{type}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Name</p>
<p className="mt-2 text-sm">{name}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Value</p>
<p className="mt-2 text-sm">{value}</p>
</div>
{ttl && (
<div>
<p className="text-sm text-muted-foreground">TTL</p>
<p className="mt-2 text-sm">{ttl}</p>
</div>
)}
</div>
);
}
export function useDomainStatus(domain: string) {
const { data, isLoading } = useSWR(
`domain-status-${domain}`,
// Server actions aren't really meant to be used for data fetching
// They are changing this in the future with server functions, when that is updated this will be too
() => getDomainStatus(domain),
{
refreshInterval: 20000, // every 20 seconds
},
);
return {
status: data?.status,
domainJson: data?.domainJson,
loading: isLoading,
};
}
const getSubdomain = (name: string, apexName: string) => {
if (name === apexName) return null;
return name.slice(0, name.length - apexName.length - 1);
};
const InlineSnippet = ({
className,
children,
}: {
className?: string;
children: string;
}) => {
return (
<span
className={cn(
"inline-block rounded-md px-1 py-0.5 font-mono bg-primary-foreground",
className,
)}
>
{children}
</span>
);
};
function DomainConfiguration(props: { domain: string }) {
const { domain } = props;
const { status, domainJson } = useDomainStatus(domain);
if (!status || status === "Valid Configuration" || !domainJson) return null;
const subdomain = getSubdomain(domainJson.name, domainJson.apexName);
const txtVerification =
(status === "Pending Verification" &&
domainJson.verification.find((x) => x.type === "TXT")) ||
null;
if (status === "Unknown Error") {
return <p className="mb-5 text-sm">{domainJson.error.message}</p>;
}
const selectedTab = txtVerification
? "txt"
: domainJson.name === domainJson.apexName
? "apex"
: "subdomain";
return (
<CardFooter className="border-t-2 border-muted flex justify-start flex-grow pt-4">
<Tabs value={selectedTab} className="w-full">
<TabsList className="bg-background border-b-2 rounded-none border-b-muted w-full justify-start ">
<TabsTrigger
value="txt"
className={cn(
"bg-background p-2 hidden text-muted-foreground rounded-none border-b-muted",
selectedTab === "txt" &&
"border-b-primary block border-b-2 text-primary",
)}
>
Domain Verification
</TabsTrigger>
<TabsTrigger
value="subdomain"
className={cn(
"bg-background p-2 text-muted-foreground hidden rounded-none border-b-muted",
selectedTab === "subdomain" &&
"border-b-primary block border-b-2 text-primary",
)}
>
CNAME
</TabsTrigger>
<TabsTrigger
value="apex"
className={cn(
"bg-background hidden p-2 text-muted-foreground rounded-none border-b-muted",
selectedTab === "apex" &&
"border-b-primary block border-b-2 text-primary",
)}
>
Apex
</TabsTrigger>
</TabsList>
{txtVerification && (
<TabsContent value="txt">
<div className="flex flex-col space-y-4 pt-4">
<p className="text-sm text-muted-foreground ">
Please set the following TXT record on{" "}
<InlineSnippet>{domainJson.apexName}</InlineSnippet> to prove
ownership of <InlineSnippet>{domainJson.name}</InlineSnippet>:
</p>
<DNSRecordDisplay
name={txtVerification.domain.slice(
0,
txtVerification.domain.length -
domainJson.apexName.length -
1,
)}
type={txtVerification.type}
value={txtVerification.value}
/>
<p className="text-sm text-muted-foreground mt-4">
Warning: if you are using this domain for another site, setting
this TXT record will transfer domain ownership away from that
site and break it. Please exercise caution when setting this
record.
</p>
</div>
</TabsContent>
)}
<TabsContent value="subdomain">
<div className="flex flex-col gap-4 pt-4">
<span className="text-sm">
To configure your subdomain{" "}
<InlineSnippet>{domainJson.name}</InlineSnippet>, set the
following CNAME record on your DNS provider to continue:
</span>
<DNSRecordDisplay
type={"CNAME"}
name={subdomain ?? "www"}
value={CNAME_VALUE}
ttl="86400"
/>
</div>
</TabsContent>
<TabsContent value="apex">
<div className="flex flex-col gap-4 pt-4">
<span className="text-sm">
To configure your domain{" "}
<InlineSnippet>{domainJson.apexName}</InlineSnippet>, set the
following A record on your DNS provider to continue:
</span>
<DNSRecordDisplay type="A" name="@" value={A_VALUE} ttl="86400" />
</div>
</TabsContent>
{selectedTab !== "txt" && (
<div className="my-3 text-left">
<p className="mt-5 text-sm dark:text-white">
Note: for TTL, if <InlineSnippet>86400</InlineSnippet> is not
available, set the highest value possible. Also, domain
propagation can take up to an hour.
</p>
</div>
)}
</Tabs>
</CardFooter>
);
}
export function DomainStatus({ domain }: { domain: string }) {
const { status, loading } = useDomainStatus(domain);
if (loading) {
return <LoaderCircle className="dark:text-white text-black animate-spin" />;
}
if (status === "Valid Configuration") {
return (
<CheckCircle2
fill="#2563EB"
stroke="currentColor"
className="text-white dark:text-white"
/>
);
}
if (status === "Pending Verification") {
return (
<AlertCircle
fill="#FBBF24"
stroke="currentColor"
className="text-white dark:text-black"
/>
);
}
if (status === "Domain Not Found") {
return (
<XCircle
fill="#DC2626"
stroke="currentColor"
className="text-white dark:text-black"
/>
);
}
if (status === "Invalid Configuration") {
return (
<XCircle
fill="#DC2626"
stroke="currentColor"
className="text-white dark:text-black"
/>
);
}
return null;
}
export function CustomDomainConfigurator(props: {
defaultDomain?: string;
}) {
const [domain, setDomain] = useState<string | null>(
props.defaultDomain ?? null,
);
const { pending } = useFormStatus();
return (
<Card className="flex flex-col space-y-6">
<form
onSubmit={async (event) => {
event.preventDefault(); // Prevent form from reloading the page
const data = new FormData(event.currentTarget);
const domain = data.get("customDomain") as string;
setDomain(domain);
await addDomain(domain);
}}
>
<CardHeader>
<CardTitle className="text-lg font-semibold">Custom Domain</CardTitle>
<CardDescription>The custom domain for your site.</CardDescription>
</CardHeader>
<CardContent className="relative bg-background flex flex-row items-center justify-between w-full">
<Input
type="text"
name="customDomain"
placeholder={"example.com"}
maxLength={64}
className="max-w-sm bg-background"
defaultValue={props.defaultDomain}
/>
<div className="flex items-center space-x-2">
{domain && <DomainStatus domain={domain} />}
<Button type="submit" variant="outline">
{pending ? <LoaderCircle className="animate-spin" /> : "Save"}
</Button>
</div>
</CardContent>
{domain && <DomainConfiguration domain={domain} />}
</form>
</Card>
);
}
Examples
Custom domain component pending txt verification
Custom domain component CNAME configuration
Custom domain component Apex configuration
Successfully added domain