Skip to content

Commit f12b4dc

Browse files
authored
feat(live-preview): supports relative urls for dynamic preview deployments (#9746)
When deploying to Vercel, preview deployment URLs are dynamically generated. This breaks Live Preview within those deployments because there is no mechanism by which we can detect and set that URL within Payload. Although Vercel provides various environment variables at our disposal, they provide no concrete identifier for exactly _which_ URL is being currently previewed (you an access the same deployment from a number of different URLs). The fix is to support _relative_ live preview URLs, that way Payload can prepend the application's top-level domain dynamically at render-time in order to create a fully qualified URL. So when you visit a Vercel preview deployment, for example, that deployment's unique URL is used to load the iframe of the preview window, instead of the application's root/production domain. Note: this does not fix multi-tenancy single-domain setups, as those still require a static top-level domain for each tenant.
1 parent 8e26824 commit f12b4dc

File tree

14 files changed

+57
-37
lines changed

14 files changed

+57
-37
lines changed

docs/live-preview/overview.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ _\* An asterisk denotes that a property is required._
5454

5555
The `url` property is a string that points to your front-end application. This value is used as the `src` attribute of the iframe rendering your front-end. Once loaded, the Admin Panel will communicate directly with your app through `window.postMessage` events.
5656

57+
This can be an absolute URL or a relative path. If you are using a relative path, Payload will resolve it relative to the application's origin URL. This is useful for Vercel preview deployments, for example, where URLs are not known ahead of time.
58+
5759
To set the URL, use the `admin.livePreview.url` property in your [Payload Config](../configuration/overview):
5860

5961
```ts

packages/next/src/views/LivePreview/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = a
3636
},
3737
]
3838

39-
const url =
39+
let url =
4040
typeof livePreviewConfig?.url === 'function'
4141
? await livePreviewConfig.url({
4242
collectionConfig,
@@ -47,5 +47,10 @@ export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = a
4747
})
4848
: livePreviewConfig?.url
4949

50+
// Support relative URLs by prepending the origin, if necessary
51+
if (url && url.startsWith('/')) {
52+
url = `${initPageResult.req.protocol}//${initPageResult.req.host}${url}`
53+
}
54+
5055
return <LivePreviewClient breakpoints={breakpoints} initialData={doc} url={url} />
5156
}

test/live-preview/app/live-preview/(pages)/[slug]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-restricted-exports */
12
import { notFound } from 'next/navigation.js'
23
import React from 'react'
34

@@ -32,10 +33,11 @@ export default async function Page({ params: paramsPromise }: Args) {
3233

3334
export async function generateStaticParams() {
3435
process.env.PAYLOAD_DROP_DATABASE = 'false'
36+
3537
try {
3638
const pages = await getDocs<Page>('pages')
3739
return pages?.map(({ slug }) => slug)
38-
} catch (error) {
40+
} catch (_err) {
3941
return []
4042
}
4143
}

test/live-preview/app/live-preview/_api/getDoc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export const getDoc = async <T>(args: {
3232
return docs[0] as T
3333
}
3434
} catch (err) {
35-
console.log('Error getting doc', err)
35+
throw new Error(`Error getting doc: ${err.message}`)
3636
}
3737

38-
throw new Error('Error getting doc')
38+
throw new Error('No doc found')
3939
}
Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import config from '@payload-config'
2-
import { getPayload } from 'payload'
2+
import { type CollectionSlug, getPayload } from 'payload'
33

4-
export const getDocs = async <T>(collection: string): Promise<T[]> => {
4+
export const getDocs = async <T>(collection: CollectionSlug): Promise<T[]> => {
55
const payload = await getPayload({ config })
66

77
try {
@@ -11,10 +11,12 @@ export const getDocs = async <T>(collection: string): Promise<T[]> => {
1111
limit: 100,
1212
})
1313

14-
return docs as T[]
14+
if (docs) {
15+
return docs as T[]
16+
}
1517
} catch (err) {
16-
console.error(err)
18+
throw new Error(`Error getting docs: ${err.message}`)
1719
}
1820

19-
throw new Error('Error getting docs')
21+
throw new Error('No docs found')
2022
}

test/live-preview/app/live-preview/_components/Footer/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export async function Footer() {
2121
<img
2222
alt="Payload Logo"
2323
className={classes.logo}
24-
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
24+
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
2525
/>
2626
</picture>
2727
</Link>
@@ -30,7 +30,7 @@ export async function Footer() {
3030
return <CMSLink key={i} {...link} />
3131
})}
3232
<Link href="/admin">Admin</Link>
33-
<Link href="https://github.com/payloadcms/payload/tree/main/templates/ecommerce">
33+
<Link href="https://github.com/payloadcms/payload/tree/main/test/live-preview">
3434
Source Code
3535
</Link>
3636
<Link href="https://github.com/payloadcms/payload">Payload</Link>

test/live-preview/app/live-preview/_components/Link/index.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import LinkWithDefault from 'next/link.js'
1+
import NextLinkImport from 'next/link.js'
22
import React from 'react'
33

44
import type { Page, Post } from '../../../../payload-types.js'
55
import type { Props as ButtonProps } from '../Button/index.js'
66

77
import { Button } from '../Button/index.js'
88

9-
const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default
9+
const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default
1010

1111
type CMSLinkType = {
1212
appearance?: ButtonProps['appearance']
@@ -36,20 +36,22 @@ export const CMSLink: React.FC<CMSLinkType> = ({
3636
}) => {
3737
const href =
3838
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
39-
? `/${reference.value.slug}`
39+
? `/live-preview/${reference.value.slug}`
4040
: url
4141

42-
if (!href) return null
42+
if (!href) {
43+
return null
44+
}
4345

4446
if (!appearance) {
4547
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
4648

4749
if (href || url) {
4850
return (
49-
<Link {...newTabProps} className={className} href={href || url || ''}>
51+
<NextLink {...newTabProps} className={className} href={href || url || ''}>
5052
{label && label}
51-
{children && children}
52-
</Link>
53+
{children || null}
54+
</NextLink>
5355
)
5456
}
5557
}

test/live-preview/prod/app/live-preview/(pages)/[slug]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-restricted-exports */
12
import { notFound } from 'next/navigation.js'
23
import React from 'react'
34

@@ -32,10 +33,11 @@ export default async function Page({ params: paramsPromise }: Args) {
3233

3334
export async function generateStaticParams() {
3435
process.env.PAYLOAD_DROP_DATABASE = 'false'
36+
3537
try {
3638
const pages = await getDocs<Page>('pages')
3739
return pages?.map(({ slug }) => slug)
38-
} catch (error) {
40+
} catch (_err) {
3941
return []
4042
}
4143
}

test/live-preview/prod/app/live-preview/_api/getDoc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ export const getDoc = async <T>(args: {
3232
return docs[0] as T
3333
}
3434
} catch (err) {
35-
console.log('Error getting doc', err)
35+
throw new Error(`Error getting doc: ${err.message}`)
3636
}
3737

38-
throw new Error('Error getting doc')
38+
throw new Error('No doc found')
3939
}
Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import config from '@payload-config'
2-
import { getPayload } from 'payload'
2+
import { type CollectionSlug, getPayload } from 'payload'
33

4-
export const getDocs = async <T>(collection: string): Promise<T[]> => {
4+
export const getDocs = async <T>(collection: CollectionSlug): Promise<T[]> => {
55
const payload = await getPayload({ config })
66

77
try {
@@ -11,10 +11,12 @@ export const getDocs = async <T>(collection: string): Promise<T[]> => {
1111
limit: 100,
1212
})
1313

14-
return docs as T[]
14+
if (docs) {
15+
return docs as T[]
16+
}
1517
} catch (err) {
16-
console.error(err)
18+
throw new Error(`Error getting docs: ${err.message}`)
1719
}
1820

19-
throw new Error('Error getting docs')
21+
throw new Error('No docs found')
2022
}

test/live-preview/prod/app/live-preview/_components/Footer/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export async function Footer() {
2121
<img
2222
alt="Payload Logo"
2323
className={classes.logo}
24-
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
24+
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
2525
/>
2626
</picture>
2727
</Link>
@@ -30,7 +30,7 @@ export async function Footer() {
3030
return <CMSLink key={i} {...link} />
3131
})}
3232
<Link href="/admin">Admin</Link>
33-
<Link href="https://github.com/payloadcms/payload/tree/main/templates/ecommerce">
33+
<Link href="https://github.com/payloadcms/payload/tree/main/test/live-preview">
3434
Source Code
3535
</Link>
3636
<Link href="https://github.com/payloadcms/payload">Payload</Link>

test/live-preview/prod/app/live-preview/_components/Link/index.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import LinkWithDefault from 'next/link.js'
1+
import NextLinkImport from 'next/link.js'
22
import React from 'react'
33

44
import type { Page, Post } from '../../../../../payload-types.js'
55
import type { Props as ButtonProps } from '../Button/index.js'
66

77
import { Button } from '../Button/index.js'
88

9-
const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default
9+
const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default
1010

1111
type CMSLinkType = {
1212
appearance?: ButtonProps['appearance']
@@ -36,20 +36,22 @@ export const CMSLink: React.FC<CMSLinkType> = ({
3636
}) => {
3737
const href =
3838
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
39-
? `/${reference.value.slug}`
39+
? `/live-preview/${reference.value.slug}`
4040
: url
4141

42-
if (!href) return null
42+
if (!href) {
43+
return null
44+
}
4345

4446
if (!appearance) {
4547
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
4648

4749
if (href || url) {
4850
return (
49-
<Link {...newTabProps} className={className} href={href || url || ''}>
51+
<NextLink {...newTabProps} className={className} href={href || url || ''}>
5052
{label && label}
51-
{children && children}
52-
</Link>
53+
{children || null}
54+
</NextLink>
5355
)
5456
}
5557
}

test/live-preview/seed/posts-page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { postsSlug } from '../shared.js'
44

55
export const postsPage: Partial<Page> = {
66
title: 'Posts',
7-
slug: 'live-preview/posts',
7+
slug: 'posts',
88
meta: {
99
title: 'Payload Website Template',
1010
description: 'An open-source website built with Payload and Next.js.',

test/live-preview/utilities/formatLivePreviewURL.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const formatLivePreviewURL: LivePreviewConfig['url'] = async ({
55
collectionConfig,
66
payload,
77
}) => {
8-
let baseURL = 'http://localhost:3000/live-preview'
8+
let baseURL = '/live-preview'
99

1010
// You can run async requests here, if needed
1111
// For example, multi-tenant apps may need to lookup additional data
@@ -25,6 +25,7 @@ export const formatLivePreviewURL: LivePreviewConfig['url'] = async ({
2525
.then((res) => res?.docs?.[0])
2626

2727
if (fullTenant?.clientURL) {
28+
// Note: appending a fully-qualified URL here won't work for preview deployments on Vercel
2829
baseURL = `${fullTenant.clientURL}/live-preview`
2930
}
3031
} catch (e) {

0 commit comments

Comments
 (0)