fix(studio): add return to Vercel escape hatch (#48311)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix / UX improvement for the Vercel Deploy Button create-project
interstitial.

## What is the current behavior?

On the Vercel create-project step, the organization picker is locked
(correct — the integration is bound to that org) and Cancel is hidden.
If the org can't create a free project (member free-project limits),
users hit a dead end: Upgrade may not help, and there's no way out of
the popup.

Also includes a small capitalisation nit on the Vercel install page.

| Before |
| --- |
| <img width="800" height="629" alt="Create Vercel Project Supabase"
src="https://github.com/user-attachments/assets/2acdc7a3-eb99-43c6-9135-557370647da1"
/> |

## What is the new behavior?

- Replaces `hideCancelButton` with `cancelAction: 'studio' | 'vercel' |
'hidden'`
- Vercel create flow shows **Return to Vercel**, which redirects to the
install `next` URL (closing the popup cleanly)
- Free-project-limit admonition adds a Vercel-only hint pointing at that
button: “Or return to Vercel and restart with a different organization.”
- Main `/new` Cancel behaviour is unchanged
- Org picker stays disabled

## Additional context

Org switching mid-create is intentionally not allowed. That would orphan
the Vercel install. Returning to Vercel is the safe escape hatch so
users can restart Deploy Button with another org, or free a project slot
/ upgrade and try again.

## To test

As far as I can tell, this is impossible to test on prod. Shortly after
merge though, you could test the following:
- [ ] Happy path: create still works; Return to Vercel is secondary and
does not block submit
- [ ] Free-limit blocked org: Create disabled, Return to Vercel visible
and redirects to `next`
- [ ] Main `/new`: Cancel still goes to last org / organizations

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Enhanced project creation flow for Vercel: when a valid return
destination is available, users can choose **“Return to Vercel”**.
- Added additional messaging in the free-project-limit warning to guide
users back to Vercel and restart with a different organization (when
applicable).
- **Bug Fixes**
- Improved cancel behavior and routing consistency by only enabling
Vercel return when the destination is valid.
- **Style**
- Updated the Vercel integration interstitial title capitalization for
consistency.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Danny WhiteandJoshen Lim authored and GitHub committed 2026-07-28 08:14:01 +10:00
1 parent 7838676902
commit c9ed51c99e
6 files changed
+82 -15

No files matched your search

@@ -0,0 +1,32 @@
import { describe, expect, test } from 'vitest'
import {
getValidVercelReturnUrl,
isVercelUrl,
} from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils'
describe('isVercelUrl', () => {
test('accepts https vercel.com urls', () => {
expect(isVercelUrl('https://vercel.com/callback')).toBe(true)
})
test('rejects non-vercel and invalid urls', () => {
expect(isVercelUrl('https://example.com')).toBe(false)
expect(isVercelUrl('http://vercel.com')).toBe(false)
expect(isVercelUrl('not-a-url')).toBe(false)
})
})
describe('getValidVercelReturnUrl', () => {
test('returns the url when it is a valid vercel return url', () => {
expect(getValidVercelReturnUrl('https://vercel.com/callback')).toBe(
'https://vercel.com/callback'
)
})
test('returns undefined for missing or invalid next values', () => {
expect(getValidVercelReturnUrl(undefined)).toBeUndefined()
expect(getValidVercelReturnUrl('https://example.com')).toBeUndefined()
expect(getValidVercelReturnUrl('not-a-url')).toBeUndefined()
})
})
@@ -10,6 +10,12 @@ export function isVercelUrl(url: string): boolean {
}
}
/** Returns `next` when it is a safe Vercel return URL; otherwise undefined. */
export function getValidVercelReturnUrl(next: string | undefined): string | undefined {
if (typeof next === 'string' && isVercelUrl(next)) return next
return undefined
}
export function findVercelIntegrationByConfigurationId(
integrations: Integration[] | undefined,
configurationId: string | undefined
@@ -6,9 +6,13 @@ import type { MemberWithFreeProjectLimit } from '@/data/organizations/free-proje
interface FreeProjectLimitWarningProps {
membersExceededLimit: MemberWithFreeProjectLimit[]
showVercelReturnHint?: boolean
}
export const FreeProjectLimitWarning = ({ membersExceededLimit }: FreeProjectLimitWarningProps) => {
export const FreeProjectLimitWarning = ({
membersExceededLimit,
showVercelReturnHint = false,
}: FreeProjectLimitWarningProps) => {
return (
<Panel.Content>
<Admonition
@@ -32,6 +36,11 @@ export const FreeProjectLimitWarning = ({ membersExceededLimit }: FreeProjectLim
These members will need to either delete, pause, or upgrade one or more of these
projects before you're able to create a free project within this organization.
</p>
{showVercelReturnHint && (
<p className="text-sm leading-normal">
Or return to Vercel and restart with a different organization.
</p>
)}
<UpgradePlanButton
source="freeProjectLimitWarning"
@@ -1,4 +1,4 @@
import { useFlag } from 'common'
import { useFlag, useParams } from 'common'
import { useRouter } from 'next/router'
import { UseFormReturn } from 'react-hook-form'
import {
@@ -16,12 +16,15 @@ import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { CreateProjectForm } from './ProjectCreation.schema'
import { instanceLabel, monthlyInstancePrice } from './ProjectCreation.utils'
import { getValidVercelReturnUrl } from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils'
import { InlineLink } from '@/components/ui/InlineLink'
import { OrgProject } from '@/data/projects/org-projects-infinite-query'
import { useLastVisitedOrganization } from '@/hooks/misc/useLastVisitedOrganization'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { DOCS_URL } from '@/lib/constants'
export type ProjectCreationCancelAction = 'studio' | 'vercel' | 'hidden'
interface ProjectCreationFooterProps {
form: UseFormReturn<CreateProjectForm>
canCreateProject: boolean
@@ -29,7 +32,7 @@ interface ProjectCreationFooterProps {
organizationProjects: OrgProject[]
isCreatingNewProject: boolean
isSuccessNewProject: boolean
hideCancelButton: boolean
cancelAction?: ProjectCreationCancelAction
}
export const ProjectCreationFooter = ({
@@ -39,9 +42,10 @@ export const ProjectCreationFooter = ({
organizationProjects,
isCreatingNewProject,
isSuccessNewProject,
hideCancelButton,
cancelAction = 'studio',
}: ProjectCreationFooterProps) => {
const router = useRouter()
const { next } = useParams()
const { data: currentOrg } = useSelectedOrganizationQuery()
const isFreePlan = currentOrg?.plan?.id === 'free'
const { lastVisitedOrganization } = useLastVisitedOrganization()
@@ -53,6 +57,9 @@ export const ProjectCreationFooter = ({
? 0
: monthlyInstancePrice(instanceSize) - availableComputeCredits
const vercelReturnUrl = getValidVercelReturnUrl(next)
const canReturnToVercel = cancelAction === 'vercel' && vercelReturnUrl !== undefined
// [kevin] This will eventually all be provided by a new API endpoint to preview and validate project creation, this is just for kaizen now
const monthlyComputeCosts =
// current project costs
@@ -66,6 +73,17 @@ export const ProjectCreationFooter = ({
// compute credits
10
const onCancel = () => {
if (canReturnToVercel && vercelReturnUrl) {
window.location.href = vercelReturnUrl
return
}
// Fall back to Studio when cancelAction is studio, or when vercel next is missing/invalid
if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`)
else router.push('/organizations')
}
return (
<div key="panel-footer" className="grid grid-cols-12 w-full gap-4 items-center">
<div className="col-span-4">
@@ -169,16 +187,13 @@ export const ProjectCreationFooter = ({
</div>
<div className="flex items-end col-span-8 space-x-2 ml-auto">
{!hideCancelButton && (
{cancelAction !== 'hidden' && (
<Button
variant="default"
disabled={isCreatingNewProject || isSuccessNewProject}
onClick={() => {
if (!!lastVisitedOrganization) router.push(`/org/${lastVisitedOrganization}`)
else router.push('/organizations')
}}
onClick={onCancel}
>
Cancel
{canReturnToVercel ? 'Return to Vercel' : 'Cancel'}
</Button>
)}
<Button
@@ -35,6 +35,7 @@ import { ProjectNameInput } from './ProjectNameInput'
import { RegionSelector } from './RegionSelector'
import { SecurityOptions } from './SecurityOptions'
import { AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } from '@/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants'
import { getValidVercelReturnUrl } from '@/components/interfaces/Integrations/Vercel/VercelIntegration.utils'
import {
GitHubRepositoryField,
useGitHubRepositoryOptions,
@@ -90,7 +91,7 @@ interface ProjectCreationFormProps {
* - "Internal configuration" section
* - "GitHub repository" field
* - "Free project info" at the bottom
* - "Cancel" button
* - Shows Cancel as "Return to Vercel" (via `next`) instead of navigating into Studio
* - Shows the following:
* - "Data seeding" section
* - When embedded in the Vercel interstitial, flattens Panel chrome so the shared
@@ -105,7 +106,8 @@ export const ProjectCreationForm = ({
const track = useTrack()
const router = useRouter()
const { profile } = useProfile()
const { slug, projectName, externalId } = useParams()
const { slug, projectName, externalId, next } = useParams()
const canReturnToVercel = getValidVercelReturnUrl(next) !== undefined
const trackFunnelError = useTrackFunnelError()
const defaultProvider = useDefaultProvider()
@@ -607,7 +609,7 @@ export const ProjectCreationForm = ({
organizationProjects={organizationProjects}
isCreatingNewProject={isCreatingNewProject}
isSuccessNewProject={isSuccessNewProject}
hideCancelButton={isVercelIntegrationFlow}
cancelAction={isVercelIntegrationFlow ? 'vercel' : 'studio'}
/>
}
>
@@ -700,7 +702,10 @@ export const ProjectCreationForm = ({
{freePlanWithExceedingLimits ? (
isAdmin &&
slug && (
<FreeProjectLimitWarning membersExceededLimit={membersExceededLimit || []} />
<FreeProjectLimitWarning
membersExceededLimit={membersExceededLimit || []}
showVercelReturnHint={isVercelIntegrationFlow && canReturnToVercel}
/>
)
) : hasOutstandingInvoices ? (
<Panel.Content>
@@ -250,7 +250,7 @@ const VercelIntegration: NextPageWithLayout = () => {
<InterstitialLayout
logo={<VercelIntegrationLogo />}
title="Install Vercel Integration"
title="Install Vercel integration"
description="Choose the Supabase organization Vercel can connect to"
footer={<VercelIntegrationFooter />}
>