├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── app ├── api │ ├── domains │ │ └── route.ts │ ├── log.ts │ └── redirect │ │ └── route.ts ├── blog │ ├── [slug] │ │ └── page.tsx │ └── page.tsx ├── globals.css ├── layout.tsx ├── page.tsx ├── robots.ts ├── shopify │ ├── blog │ │ └── page.tsx │ └── page.tsx └── sitemap.ts ├── blog ├── 10-tips-for-a-killer-domain-name.mdx ├── find-the-perfect-domain-name.mdx ├── how-to-brainstorm-domain-names.mdx ├── how-to-choose-the-right-domain-name-extension.mdx ├── how-to-optimize-your-shopify-domain-name-for-seo.mdx ├── how-to-use-a-free-domain-name-generator.mdx ├── the-benefits-of-using-a-shopify-domain-name-generator.mdx └── the-dos-and-donts-of-choosing-a-domain-name.mdx ├── components ├── AdvancedOptions.tsx ├── BlogAll.tsx ├── BlogPreview.tsx ├── CallToAction.tsx ├── Features.tsx ├── Footer.tsx ├── Main.tsx ├── Metadata.tsx └── useDynamicPlaceholder.ts ├── constants ├── affiliates.ts └── tlds.ts ├── images ├── bluehost.svg ├── domaincom.svg ├── godaddy.svg ├── google.svg ├── hostinger.svg ├── namecheap.svg ├── screenshot.png └── shopify.svg ├── mdx-components.tsx ├── next.config.mjs ├── package.json ├── postcss.config.js ├── public ├── favicon.svg └── opengraph.jpg ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.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 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "editorconfig.editorconfig", 5 | "dbaeumer.vscode-eslint", 6 | "bradlc.vscode-tailwindcss" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit" 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnSave": true, 7 | "typescript.tsdk": "node_modules/typescript/lib", 8 | "typescript.enablePromptUseWorkspaceTsdk": true 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Screenshot of recommend.domains](./images/screenshot.png)](https://recommend.domains) 2 | 3 | # [recommend.domains](https://recommend.domains) 4 | 5 | Find the perfect domain name for your next project using [ChatGPT](https://openai.com/blog/chatgpt) and the [GoDaddy API](https://developer.godaddy.com/). Built using [Next.js](https://nextjs.org/) 13, [Tailwind](https://tailwindcss.com/) and deployed on [Vercel](https://vercel.com/). 6 | 7 | ## Run locally 8 | 9 | ### Clone the repository 10 | 11 | ```sh 12 | git clone https://github.com/gregives/recommend.domains 13 | ``` 14 | 15 | ### Create an OpenAI API key 16 | 17 | 1. Log in or sign up at https://platform.openai.com/ 18 | 2. Click on your profile photo in the top right 19 | 3. View API keys 20 | 4. Create new secret key 21 | 22 | ### Create a `.env` file 23 | 24 | 1. Create a file called `.env.local` in the root of the repository 25 | 2. Paste your OpenAPI secret key into the `.env.local` file in this format: 26 | 27 | ```env 28 | OPENAI_API_KEY=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 29 | ``` 30 | 31 | ### Create API key and secret for GoDaddy 32 | 33 | 1. Log in or sign up at https://developer.godaddy.com/ 34 | 2. Click API Keys in the menu 35 | 3. Create New API Key 36 | 4. Choose the `ote` environment 37 | 5. Paste the key and secret into the `.env.local` file: 38 | 39 | ```env 40 | OPENAI_API_KEY=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 41 | GODADDY_URL=https://api.ote-godaddy.com 42 | GODADDY_API_KEY=3mM44UcgtKAewW_5rDWLN1QsnNhxD1uJ2kL55 43 | GODADDY_API_SECRET=UWAVgpM1kvWhkbCsvZfYhp 44 | ``` 45 | 46 | ### Install dependencies 47 | 48 | ```sh 49 | yarn 50 | ``` 51 | 52 | ### Run the website locally 53 | 54 | ```sh 55 | yarn dev 56 | ``` 57 | 58 | You should now be able to head to http://localhost:3000 and see the recommend.domains website. 59 | 60 | ## Contributions 61 | 62 | If there's a change you'd like to make to the recommend.domains website, such as adding new features, I'd love for you to open a pull request and I'll take a look! 63 | -------------------------------------------------------------------------------- /app/api/domains/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { 3 | createParser, 4 | ParsedEvent, 5 | ReconnectInterval, 6 | } from "eventsource-parser"; 7 | import { Options } from "@/components/AdvancedOptions"; 8 | import { log } from "../log"; 9 | 10 | export type Domain = { 11 | available: boolean; 12 | definitive: boolean; 13 | domain: string; 14 | period?: number; 15 | price?: number; 16 | currency?: string; 17 | }; 18 | 19 | async function getAvailableDomains(domainNames: string[]) { 20 | if (domainNames.length === 0) { 21 | return []; 22 | } 23 | 24 | const response = await fetch( 25 | `${process.env.GODADDY_URL}/v1/domains/available`, 26 | { 27 | method: "POST", 28 | cache: "no-store", 29 | headers: { 30 | Authorization: `sso-key ${process.env.GODADDY_API_KEY}:${process.env.GODADDY_API_SECRET}`, 31 | "Content-Type": "application/json", 32 | }, 33 | body: JSON.stringify(domainNames), 34 | } 35 | ); 36 | 37 | // If GoDaddy are throttling us then we assume all domains are available 38 | if (!response.ok) { 39 | return domainNames.map((domainName) => ({ 40 | available: true, 41 | definitive: false, 42 | domain: domainName, 43 | })); 44 | } 45 | 46 | const availability: { domains: Domain[] } = await response.json(); 47 | 48 | return availability.domains.filter((domain) => domain.available); 49 | } 50 | 51 | let domainRegex: RegExp; 52 | 53 | async function initialize() { 54 | if (domainRegex) { 55 | return; 56 | } 57 | 58 | const tlds: { name: string; type: "COUNTRY_CODE" | "GENERIC" }[] = 59 | await fetch(`${process.env.GODADDY_URL}/v1/domains/tlds`, { 60 | headers: { 61 | Authorization: `sso-key ${process.env.GODADDY_API_KEY}:${process.env.GODADDY_API_SECRET}`, 62 | }, 63 | }).then((response) => response.json()); 64 | 65 | domainRegex = new RegExp( 66 | `[a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.(?:${tlds 67 | .map(({ name }) => name.replace(/\./g, "\\.")) 68 | .join("|")})`, 69 | "gi" 70 | ); 71 | } 72 | 73 | const textDecoder = new TextDecoder(); 74 | const textEncoder = new TextEncoder(); 75 | 76 | export async function POST(request: NextRequest) { 77 | await initialize(); 78 | 79 | let { description, options }: { description: string; options: Options } = 80 | await request.json(); 81 | 82 | // Make sure description is 100 characters or less 83 | description = description.slice(0, 100); 84 | 85 | let prompt = "List some suitable domain names for my project in CSV format. "; 86 | 87 | if (options.tlds.length > 0) { 88 | const lastTld = options.tlds.pop(); 89 | prompt += `Suggest domain names that end in ${options.tlds.join(", ")}${ 90 | options.tlds.length > 0 ? ` or ${lastTld}` : lastTld 91 | }. `; 92 | } 93 | 94 | if (options.numberOfWords > 0) { 95 | prompt += `Suggest domain names that are ${options.numberOfWords} word${ 96 | options.numberOfWords !== 1 ? "s" : "" 97 | } long. `; 98 | } 99 | 100 | prompt += `Description of my project: "${description}"`; 101 | 102 | const response = await fetch("https://api.openai.com/v1/chat/completions", { 103 | method: "POST", 104 | cache: "no-store", 105 | headers: { 106 | "Content-Type": "application/json", 107 | Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 108 | }, 109 | body: JSON.stringify({ 110 | model: "gpt-3.5-turbo", 111 | stream: true, 112 | max_tokens: 200, 113 | messages: [ 114 | { 115 | role: "user", 116 | content: prompt, 117 | }, 118 | ], 119 | }), 120 | }); 121 | 122 | const responseBody = response.body; 123 | 124 | if (responseBody === null) { 125 | throw new Error("Invalid response from OpenAI"); 126 | } 127 | 128 | let completeResponse = ""; 129 | const domainNamesFound: string[] = []; 130 | const pendingPromises: Promise[] = []; 131 | 132 | const stream = new ReadableStream({ 133 | async start(controller) { 134 | function onParse(event: ParsedEvent | ReconnectInterval) { 135 | if (event.type === "event") { 136 | const data = event.data; 137 | 138 | try { 139 | const [choice] = JSON.parse(data).choices; 140 | 141 | if (choice.delta.content === undefined) { 142 | return; 143 | } 144 | 145 | // Add delta to complete response 146 | completeResponse += choice.delta.content; 147 | 148 | // Find new domain names in the complete response 149 | const newDomainNames = [ 150 | ...(completeResponse.matchAll(domainRegex) ?? []), 151 | ] 152 | .map(([domainName]) => domainName.toLowerCase()) 153 | .filter( 154 | (domainName) => 155 | domainName.length < 25 && 156 | !domainNamesFound.includes(domainName) 157 | ); 158 | 159 | domainNamesFound.push(...newDomainNames); 160 | 161 | const pendingPromise = getAvailableDomains(newDomainNames).then( 162 | (availableDomains) => { 163 | // Return available domains separated by | 164 | if (availableDomains.length > 0) { 165 | controller.enqueue( 166 | textEncoder.encode( 167 | availableDomains 168 | .map((availableDomain) => 169 | JSON.stringify(availableDomain) 170 | ) 171 | .join("|") + "|" 172 | ) 173 | ); 174 | } 175 | 176 | log.queue( 177 | availableDomains.map((availableDomain) => ({ 178 | description, 179 | suggestion: availableDomain.domain, 180 | options: JSON.stringify(options), 181 | })) 182 | ); 183 | } 184 | ); 185 | 186 | pendingPromises.push(pendingPromise); 187 | } catch { 188 | // Ignore lines that we fail to parse 189 | } 190 | } 191 | } 192 | 193 | const parser = createParser(onParse); 194 | 195 | for await (const chunk of responseBody as any) { 196 | parser.feed(textDecoder.decode(chunk)); 197 | } 198 | 199 | // Wait for all availability checks to finish 200 | await Promise.all(pendingPromises); 201 | 202 | // Send logs 203 | await log.flush(); 204 | 205 | // Close the stream 206 | controller.close(); 207 | }, 208 | }); 209 | 210 | return new NextResponse(stream); 211 | } 212 | 213 | export const runtime = "edge"; 214 | -------------------------------------------------------------------------------- /app/api/log.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from "csv-stringify/sync"; 2 | 3 | const GIST_ID = "c3b6755871063529ad9d3067a2751462"; 4 | 5 | type Log = { 6 | description: string; 7 | suggestion: string; 8 | options: string; 9 | timestamp: number; 10 | }; 11 | 12 | const logs: Log[] = []; 13 | 14 | function queue(logsToQueue: Omit[]) { 15 | logs.push( 16 | ...logsToQueue.map((log) => ({ 17 | ...log, 18 | timestamp: Date.now(), 19 | })) 20 | ); 21 | } 22 | 23 | let numberOfDomainsGenerated = 0; 24 | 25 | async function flush() { 26 | const logsToSend = logs.splice(0, logs.length); 27 | 28 | if (logsToSend.length === 0) { 29 | return; 30 | } 31 | 32 | if (process.env.GITHUB_TOKEN === undefined) { 33 | // Used as a fallback in case process.env.GITHUB_TOKEN is undefined 34 | numberOfDomainsGenerated += 1; 35 | return; 36 | } 37 | 38 | const gist = await fetch(`https://api.github.com/gists/${GIST_ID}`, { 39 | headers: { 40 | Accept: "application/vnd.github+json", 41 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 42 | }, 43 | }).then((response) => response.json()); 44 | 45 | const filename = 46 | new Date(logsToSend[0].timestamp).toISOString().slice(0, 10) + ".csv"; 47 | const file = gist.files[filename]; 48 | 49 | const content = 50 | (file === undefined 51 | ? "Description,Suggestion,Timestamp,Options\n" 52 | : file.content) + 53 | stringify( 54 | logsToSend.map(({ description, suggestion, timestamp, options }) => [ 55 | description, 56 | suggestion, 57 | timestamp, 58 | options, 59 | ]) 60 | ); 61 | 62 | await fetch(`https://api.github.com/gists/${GIST_ID}`, { 63 | method: "PATCH", 64 | cache: "no-store", 65 | headers: { 66 | Accept: "application/vnd.github+json", 67 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 68 | }, 69 | body: JSON.stringify({ 70 | files: { 71 | [filename]: { 72 | content, 73 | }, 74 | }, 75 | }), 76 | }); 77 | } 78 | 79 | async function count() { 80 | if (process.env.GITHUB_TOKEN === undefined) { 81 | return numberOfDomainsGenerated; 82 | } 83 | 84 | const gist = await fetch(`https://api.github.com/gists/${GIST_ID}`, { 85 | headers: { 86 | Accept: "application/vnd.github+json", 87 | Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 88 | }, 89 | next: { 90 | revalidate: 3600, 91 | }, 92 | }).then((response) => response.json()); 93 | 94 | numberOfDomainsGenerated = 10000; 95 | for (const file of Object.values(gist.files)) { 96 | // @ts-ignore 97 | numberOfDomainsGenerated += file.content.split("\n").length - 1; 98 | } 99 | 100 | return numberOfDomainsGenerated; 101 | } 102 | 103 | export const log = { 104 | count, 105 | flush, 106 | queue, 107 | }; 108 | -------------------------------------------------------------------------------- /app/api/redirect/route.ts: -------------------------------------------------------------------------------- 1 | import { affiliates } from "@/constants/affiliates"; 2 | import { redirect } from "next/navigation"; 3 | import { NextRequest } from "next/server"; 4 | 5 | export async function GET(request: NextRequest) { 6 | const { searchParams } = new URL(request.url); 7 | const encodedHref = searchParams.get("href"); 8 | 9 | if (encodedHref === null) { 10 | redirect("/"); 11 | } 12 | 13 | const decodedHref = decodeURIComponent(encodedHref); 14 | 15 | const affiliate = affiliates.find((affiliate) => 16 | decodedHref.startsWith(affiliate.href) 17 | ); 18 | 19 | if (affiliate?.referral !== undefined) { 20 | redirect(affiliate.referral + encodeURI(decodedHref)); 21 | } 22 | 23 | redirect(decodedHref); 24 | } 25 | 26 | export const runtime = "edge"; 27 | -------------------------------------------------------------------------------- /app/blog/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@/components/Footer"; 2 | import { notFound } from "next/navigation"; 3 | 4 | export async function generateMetadata({ 5 | params, 6 | }: { 7 | params: { 8 | slug: string; 9 | }; 10 | }) { 11 | try { 12 | const { metadata } = await import(`@/blog/${params.slug}.mdx`); 13 | 14 | return { 15 | title: metadata.title, 16 | description: metadata.description, 17 | openGraph: { 18 | title: metadata.title, 19 | description: metadata.description, 20 | }, 21 | twitter: { 22 | title: metadata.title, 23 | description: metadata.description, 24 | }, 25 | }; 26 | } catch { 27 | return {}; 28 | } 29 | } 30 | 31 | export default async function Article({ 32 | params, 33 | }: { 34 | params: { 35 | slug: string; 36 | }; 37 | }) { 38 | try { 39 | const { default: Content } = await import(`@/blog/${params.slug}.mdx`); 40 | 41 | return ( 42 |
43 |
44 |
45 | 46 |
47 |
48 |
49 |
50 | ); 51 | } catch { 52 | notFound(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import { BlogAll } from "@/components/BlogAll"; 2 | import { Footer } from "@/components/Footer"; 3 | 4 | export default function Blog() { 5 | return ( 6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "focus-visible"; 2 | import "./globals.css"; 3 | 4 | import { GlobeAltIcon } from "@heroicons/react/24/outline"; 5 | import { Analytics } from "@vercel/analytics/react"; 6 | import { Inter, Space_Grotesk } from "next/font/google"; 7 | 8 | const inter = Inter({ 9 | variable: "--font-inter", 10 | display: "swap", 11 | subsets: ["latin"], 12 | }); 13 | 14 | const spaceGrotesk = Space_Grotesk({ 15 | variable: "--font-space-grotesk", 16 | display: "swap", 17 | subsets: ["latin"], 18 | }); 19 | 20 | export const metadata = { 21 | title: "Domain Name Generator", 22 | description: 23 | "Use artificial intelligence to find the perfect domain name for your next project.", 24 | openGraph: { 25 | title: "Domain Name Generator", 26 | description: 27 | "Use artificial intelligence to find the perfect domain name for your next project.", 28 | url: "https://recommend.domains", 29 | siteName: "Domain Name Generator", 30 | images: [ 31 | { 32 | url: "https://recommend.domains/opengraph.jpg", 33 | width: 1200, 34 | height: 630, 35 | }, 36 | ], 37 | type: "website", 38 | }, 39 | twitter: { 40 | card: "summary_large_image", 41 | title: "Domain Name Generator", 42 | description: 43 | "Use artificial intelligence to find the perfect domain name for your next project.", 44 | images: ["https://recommend.domains/opengraph.jpg"], 45 | }, 46 | }; 47 | 48 | const setThemeColors = () => { 49 | if (location.pathname.includes("shopify")) { 50 | localStorage.theme = "shopify"; 51 | } else if ( 52 | window.location.pathname === "/" || 53 | localStorage.theme === undefined 54 | ) { 55 | localStorage.theme = "normal"; 56 | } 57 | 58 | const theme = 59 | localStorage.theme === "shopify" 60 | ? { 61 | "50": "#f0fdf4", 62 | "100": "#dcfce7", 63 | "200": "#bbf7d0", 64 | "300": "#86efac", 65 | "400": "#4ade80", 66 | "500": "#22c55e", 67 | "600": "#16a34a", 68 | "700": "#15803d", 69 | "800": "#166534", 70 | "900": "#14532d", 71 | "950": "#052e16", 72 | } 73 | : { 74 | "50": "#eef2ff", 75 | "100": "#e0e7ff", 76 | "200": "#c7d2fe", 77 | "300": "#a5b4fc", 78 | "400": "#818cf8", 79 | "500": "#6366f1", 80 | "600": "#4f46e5", 81 | "700": "#4338ca", 82 | "800": "#3730a3", 83 | "900": "#312e81", 84 | "950": "#1e1b4b", 85 | }; 86 | 87 | for (const key in theme) { 88 | document.documentElement.style.setProperty( 89 | `--color-primary-${key}`, 90 | theme[key as keyof typeof theme] 91 | ); 92 | } 93 | }; 94 | 95 | export default function RootLayout({ 96 | children, 97 | }: { 98 | children: React.ReactNode; 99 | }) { 100 | return ( 101 | 105 | 106 | 107 |