feat: add Vercel integration for project deployment

This commit introduces Vercel integration, enabling users to deploy projects directly to Vercel. It includes:
- New Vercel types and store for managing connections and stats.
- A VercelConnection component for managing Vercel account connections.
- A VercelDeploymentLink component for displaying deployment links.
- API routes for handling Vercel deployments.
- Updates to the HeaderActionButtons component to support Vercel deployment.

The integration allows users to connect their Vercel accounts, view project stats, and deploy projects with ease.
This commit is contained in:
KevIsDev
2025-03-27 00:06:10 +00:00
parent 1364d4a503
commit 687b03ba74
9 changed files with 982 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import React, { Suspense } from 'react';
import VercelConnection from './VercelConnection';
// Use React.lazy for dynamic imports
const GithubConnection = React.lazy(() => import('./GithubConnection'));
@@ -39,6 +40,9 @@ export default function ConnectionsTab() {
<Suspense fallback={<LoadingFallback />}>
<NetlifyConnection />
</Suspense>
<Suspense fallback={<LoadingFallback />}>
<VercelConnection />
</Suspense>
</div>
</div>
);

View File

@@ -0,0 +1,289 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import {
vercelConnection,
isConnecting,
isFetchingStats,
updateVercelConnection,
fetchVercelStats,
} from '~/lib/stores/vercel';
export default function VercelConnection() {
const connection = useStore(vercelConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isProjectsExpanded, setIsProjectsExpanded] = useState(false);
useEffect(() => {
const fetchProjects = async () => {
if (connection.user && connection.token) {
await fetchVercelStats(connection.token);
}
};
fetchProjects();
}, [connection.user, connection.token]);
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
try {
const response = await fetch('https://api.vercel.com/v2/user', {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
const userData = (await response.json()) as any;
updateVercelConnection({
user: userData.user || userData, // Handle both possible structures
token: connection.token,
});
await fetchVercelStats(connection.token);
toast.success('Successfully connected to Vercel');
} catch (error) {
console.error('Auth error:', error);
logStore.logError('Failed to authenticate with Vercel', { error });
toast.error('Failed to connect to Vercel');
updateVercelConnection({ user: null, token: '' });
} finally {
isConnecting.set(false);
}
};
const handleDisconnect = () => {
updateVercelConnection({ user: null, token: '' });
toast.success('Disconnected from Vercel');
};
console.log('connection', connection);
return (
<motion.div
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img
className="w-5 h-5 dark:invert"
height="24"
width="24"
crossOrigin="anonymous"
src={`https://cdn.simpleicons.org/vercel/black`}
/>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Vercel Connection</h3>
</div>
</div>
{!connection.user ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
<input
type="password"
value={connection.token}
onChange={(e) => updateVercelConnection({ ...connection, token: e.target.value })}
disabled={connecting}
placeholder="Enter your Vercel personal access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-borderColorActive',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://vercel.com/account/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-borderColorActive hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-bolt-elements-borderColor text-white',
'hover:bg-bolt-elements-borderColorActive',
'disabled:opacity-50 disabled:cursor-not-allowed',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
Connected to Vercel
</span>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
{/* Debug output */}
<pre className="hidden">{JSON.stringify(connection.user, null, 2)}</pre>
<img
src={`https://vercel.com/api/www/avatar?u=${connection.user?.username || connection.user?.user?.username}`}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt="User Avatar"
className="w-12 h-12 rounded-full border-2 border-bolt-elements-borderColorActive"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">
{connection.user?.username || connection.user?.user?.username || 'Vercel User'}
</h4>
<p className="text-sm text-bolt-elements-textSecondary">
{connection.user?.email || connection.user?.user?.email || 'No email available'}
</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching Vercel projects...
</div>
) : (
<div>
<button
onClick={() => setIsProjectsExpanded(!isProjectsExpanded)}
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
>
<div className="i-ph:buildings w-4 h-4" />
Your Projects ({connection.stats?.totalProjects || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
isProjectsExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isProjectsExpanded && connection.stats?.projects?.length ? (
<div className="grid gap-3">
{connection.stats.projects.map((project) => (
<a
key={project.id}
href={`https://vercel.com/dashboard/${project.id}`}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<div className="i-ph:globe w-4 h-4 text-bolt-elements-borderColorActive" />
{project.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
{project.targets?.production?.alias && project.targets.production.alias.length > 0 ? (
<>
<a
href={`https://${project.targets.production.alias.find((a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app')) || project.targets.production.alias[0]}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive"
>
{project.targets.production.alias.find(
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
) || project.targets.production.alias[0]}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.createdAt).toLocaleDateString()}
</span>
</>
) : project.latestDeployments && project.latestDeployments.length > 0 ? (
<>
<a
href={`https://${project.latestDeployments[0].url}`}
target="_blank"
rel="noopener noreferrer"
className="hover:text-bolt-elements-borderColorActive"
>
{project.latestDeployments[0].url}
</a>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(project.latestDeployments[0].created).toLocaleDateString()}
</span>
</>
) : null}
</div>
</div>
{project.framework && (
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
<span className="flex items-center gap-1">
<div className="i-ph:code w-3 h-3" />
{project.framework}
</span>
</div>
)}
</div>
</a>
))}
</div>
) : isProjectsExpanded ? (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No projects found in your Vercel account
</div>
) : null}
</div>
)}
</div>
)}
</div>
</motion.div>
);
}

View File

@@ -30,10 +30,10 @@ export function NetlifyDeploymentLink() {
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#00AD9F] z-50"
onClick={(e) => {
e.stopPropagation(); // Add this to prevent click from bubbling up
e.stopPropagation(); // This is to prevent click from bubbling up
}}
>
<div className="i-ph:rocket-launch w-5 h-5" />
<div className="i-ph:link w-4 h-4 hover:text-blue-400" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>

View File

@@ -0,0 +1,158 @@
import { useStore } from '@nanostores/react';
import { vercelConnection } from '~/lib/stores/vercel';
import { chatId } from '~/lib/persistence/useChatHistory';
import * as Tooltip from '@radix-ui/react-tooltip';
import { useEffect, useState } from 'react';
export function VercelDeploymentLink() {
const connection = useStore(vercelConnection);
const currentChatId = useStore(chatId);
const [deploymentUrl, setDeploymentUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
async function fetchProjectData() {
if (!connection.token || !currentChatId) {
return;
}
// Check if we have a stored project ID for this chat
const projectId = localStorage.getItem(`vercel-project-${currentChatId}`);
if (!projectId) {
return;
}
setIsLoading(true);
try {
// Fetch projects directly from the API
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (!projectsResponse.ok) {
throw new Error(`Failed to fetch projects: ${projectsResponse.status}`);
}
const projectsData = (await projectsResponse.json()) as any;
const projects = projectsData.projects || [];
// Extract the chat number from currentChatId
const chatNumber = currentChatId.split('-')[0];
// Find project by matching the chat number in the name
const project = projects.find((p: { name: string | string[] }) => p.name.includes(`bolt-diy-${chatNumber}`));
if (project) {
// Fetch project details including deployments
const projectDetailsResponse = await fetch(`https://api.vercel.com/v9/projects/${project.id}`, {
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
});
if (projectDetailsResponse.ok) {
const projectDetails = (await projectDetailsResponse.json()) as any;
// Try to get URL from production aliases first
if (projectDetails.targets?.production?.alias && projectDetails.targets.production.alias.length > 0) {
// Find the clean URL (without -projects.vercel.app)
const cleanUrl = projectDetails.targets.production.alias.find(
(a: string) => a.endsWith('.vercel.app') && !a.includes('-projects.vercel.app'),
);
if (cleanUrl) {
setDeploymentUrl(`https://${cleanUrl}`);
return;
} else {
// If no clean URL found, use the first alias
setDeploymentUrl(`https://${projectDetails.targets.production.alias[0]}`);
return;
}
}
}
// If no aliases or project details failed, try fetching deployments
const deploymentsResponse = await fetch(
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
{
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
cache: 'no-store',
},
);
if (deploymentsResponse.ok) {
const deploymentsData = (await deploymentsResponse.json()) as any;
if (deploymentsData.deployments && deploymentsData.deployments.length > 0) {
setDeploymentUrl(`https://${deploymentsData.deployments[0].url}`);
return;
}
}
}
// Fallback to API call if not found in fetched projects
const fallbackResponse = await fetch(`/api/vercel-deploy?projectId=${projectId}&token=${connection.token}`, {
method: 'GET',
});
const data = await fallbackResponse.json();
if ((data as { deploy?: { url?: string } }).deploy?.url) {
setDeploymentUrl((data as { deploy: { url: string } }).deploy.url);
} else if ((data as { project?: { url?: string } }).project?.url) {
setDeploymentUrl((data as { project: { url: string } }).project.url);
}
} catch (err) {
console.error('Error fetching Vercel deployment:', err);
} finally {
setIsLoading(false);
}
}
fetchProjectData();
}, [connection.token, currentChatId]);
if (!deploymentUrl) {
return null;
}
return (
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-bolt-elements-item-backgroundActive text-bolt-elements-textSecondary hover:text-[#000000] z-50"
onClick={(e) => {
e.stopPropagation();
}}
>
<div className={`i-ph:link w-4 h-4 hover:text-blue-400 ${isLoading ? 'animate-pulse' : ''}`} />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
className="px-3 py-2 rounded bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary text-xs z-50"
sideOffset={5}
>
{deploymentUrl}
<Tooltip.Arrow className="fill-bolt-elements-background-depth-3" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@@ -3,26 +3,30 @@ import { toast } from 'react-toastify';
import useViewport from '~/lib/hooks';
import { chatStore } from '~/lib/stores/chat';
import { netlifyConnection } from '~/lib/stores/netlify';
import { vercelConnection } from '~/lib/stores/vercel';
import { workbenchStore } from '~/lib/stores/workbench';
import { webcontainer } from '~/lib/webcontainer';
import { classNames } from '~/utils/classNames';
import { path } from '~/utils/path';
import { useEffect, useRef, useState } from 'react';
import type { ActionCallbackData } from '~/lib/runtime/message-parser';
import { chatId } from '~/lib/persistence/useChatHistory'; // Add this import
import { chatId } from '~/lib/persistence/useChatHistory';
import { streamingState } from '~/lib/stores/streaming';
import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client';
import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client';
interface HeaderActionButtonsProps {}
export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const showWorkbench = useStore(workbenchStore.showWorkbench);
const { showChat } = useStore(chatStore);
const connection = useStore(netlifyConnection);
const netlifyConn = useStore(netlifyConnection);
const vercelConn = useStore(vercelConnection);
const [activePreviewIndex] = useState(0);
const previews = useStore(workbenchStore.previews);
const activePreview = previews[activePreviewIndex];
const [isDeploying, setIsDeploying] = useState(false);
const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null);
const isSmallViewport = useViewport(1024);
const canHideChat = showWorkbench || !showChat;
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@@ -42,8 +46,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const currentChatId = useStore(chatId);
const handleDeploy = async () => {
if (!connection.user || !connection.token) {
const handleNetlifyDeploy = async () => {
if (!netlifyConn.user || !netlifyConn.token) {
toast.error('Please connect to Netlify first in the settings tab!');
return;
}
@@ -118,7 +122,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
const existingSiteId = localStorage.getItem(`netlify-site-${currentChatId}`);
// Deploy using the API route with file contents
const response = await fetch('/api/deploy', {
const response = await fetch('/api/netlify-deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -126,7 +130,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
body: JSON.stringify({
siteId: existingSiteId || undefined,
files: fileContents,
token: connection.token,
token: netlifyConn.token,
chatId: currentChatId, // Use chatId instead of artifact.id
}),
});
@@ -149,7 +153,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
`https://api.netlify.com/api/v1/sites/${data.site.id}/deploys/${data.deploy.id}`,
{
headers: {
Authorization: `Bearer ${connection.token}`,
Authorization: `Bearer ${netlifyConn.token}`,
},
},
);
@@ -203,6 +207,125 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
}
};
const handleVercelDeploy = async () => {
if (!vercelConn.user || !vercelConn.token) {
toast.error('Please connect to Vercel first in the settings tab!');
return;
}
if (!currentChatId) {
toast.error('No active chat found');
return;
}
try {
setIsDeploying(true);
setDeployingTo('vercel');
const artifact = workbenchStore.firstArtifact;
if (!artifact) {
throw new Error('No active project found');
}
const actionId = 'build-' + Date.now();
const actionData: ActionCallbackData = {
messageId: 'vercel build',
artifactId: artifact.id,
actionId,
action: {
type: 'build' as const,
content: 'npm run build',
},
};
// Add the action first
artifact.runner.addAction(actionData);
// Then run it
await artifact.runner.runAction(actionData);
if (!artifact.runner.buildOutput) {
throw new Error('Build failed');
}
// Get the build files
const container = await webcontainer;
// Remove /home/project from buildPath if it exists
const buildPath = artifact.runner.buildOutput.path.replace('/home/project', '');
// Get all files recursively
async function getAllFiles(dirPath: string): Promise<Record<string, string>> {
const files: Record<string, string> = {};
const entries = await container.fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isFile()) {
const content = await container.fs.readFile(fullPath, 'utf-8');
// Remove /dist prefix from the path
const deployPath = fullPath.replace(buildPath, '');
files[deployPath] = content;
} else if (entry.isDirectory()) {
const subFiles = await getAllFiles(fullPath);
Object.assign(files, subFiles);
}
}
return files;
}
const fileContents = await getAllFiles(buildPath);
// Use chatId instead of artifact.id
const existingProjectId = localStorage.getItem(`vercel-project-${currentChatId}`);
// Deploy using the API route with file contents
const response = await fetch('/api/vercel-deploy', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
projectId: existingProjectId || undefined,
files: fileContents,
token: vercelConn.token,
chatId: currentChatId,
}),
});
const data = (await response.json()) as any;
if (!response.ok || !data.deploy || !data.project) {
console.error('Invalid deploy response:', data);
throw new Error(data.error || 'Invalid deployment response');
}
// Store the project ID if it's a new project
if (data.project) {
localStorage.setItem(`vercel-project-${currentChatId}`, data.project.id);
}
toast.success(
<div>
Deployed successfully to Vercel!{' '}
<a href={data.deploy.url} target="_blank" rel="noopener noreferrer" className="underline">
View site
</a>
</div>,
);
} catch (error) {
console.error('Vercel deploy error:', error);
toast.error(error instanceof Error ? error.message : 'Vercel deployment failed');
} finally {
setIsDeploying(false);
setDeployingTo(null);
}
};
return (
<div className="flex">
<div className="relative" ref={dropdownRef}>
@@ -213,7 +336,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
className="px-4 hover:bg-bolt-elements-item-backgroundActive flex items-center gap-2"
>
{isDeploying ? 'Deploying...' : 'Deploy'}
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
<div
className={classNames('i-ph:caret-down w-4 h-4 transition-transform', isDropdownOpen ? 'rotate-180' : '')}
/>
@@ -225,10 +348,10 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
<Button
active
onClick={() => {
handleDeploy();
handleNetlifyDeploy();
setIsDropdownOpen(false);
}}
disabled={isDeploying || !activePreview || !connection.user}
disabled={isDeploying || !activePreview || !netlifyConn.user}
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
>
<img
@@ -238,15 +361,20 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<span className="mx-auto">{!connection.user ? 'No Account Connected' : 'Deploy to Netlify'}</span>
{connection.user && <NetlifyDeploymentLink />}
<span className="mx-auto">
{!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'}
</span>
{netlifyConn.user && <NetlifyDeploymentLink />}
</Button>
<Button
active={false}
disabled
className="flex items-center w-full rounded-md px-4 py-2 text-sm text-bolt-elements-textTertiary gap-2"
active
onClick={() => {
handleVercelDeploy();
setIsDropdownOpen(false);
}}
disabled={isDeploying || !activePreview || !vercelConn.user}
className="flex items-center w-full px-4 py-2 text-sm text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive gap-2 rounded-md group relative"
>
<span className="sr-only">Coming Soon</span>
<img
className="w-5 h-5 bg-black p-1 rounded"
height="24"
@@ -255,7 +383,8 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
src="https://cdn.simpleicons.org/vercel/white"
alt="vercel"
/>
<span className="mx-auto">Deploy to Vercel (Coming Soon)</span>
<span className="mx-auto">{!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'}</span>
{vercelConn.user && <VercelDeploymentLink />}
</Button>
<Button
active={false}
@@ -269,7 +398,7 @@ export function HeaderActionButtons({}: HeaderActionButtonsProps) {
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/cloudflare"
alt="vercel"
alt="cloudflare"
/>
<span className="mx-auto">Deploy to Cloudflare (Coming Soon)</span>
</Button>