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.
This commit is contained in:
@@ -54,6 +54,8 @@ _\* An asterisk denotes that a property is required._
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
To set the URL, use the `admin.livePreview.url` property in your [Payload Config](../configuration/overview):
|
||||
|
||||
```ts
|
||||
|
||||
@@ -36,7 +36,7 @@ export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = a
|
||||
},
|
||||
]
|
||||
|
||||
const url =
|
||||
let url =
|
||||
typeof livePreviewConfig?.url === 'function'
|
||||
? await livePreviewConfig.url({
|
||||
collectionConfig,
|
||||
@@ -47,5 +47,10 @@ export const LivePreviewView: PayloadServerReactComponent<EditViewComponent> = a
|
||||
})
|
||||
: livePreviewConfig?.url
|
||||
|
||||
// Support relative URLs by prepending the origin, if necessary
|
||||
if (url && url.startsWith('/')) {
|
||||
url = `${initPageResult.req.protocol}//${initPageResult.req.host}${url}`
|
||||
}
|
||||
|
||||
return <LivePreviewClient breakpoints={breakpoints} initialData={doc} url={url} />
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-restricted-exports */
|
||||
import { notFound } from 'next/navigation.js'
|
||||
import React from 'react'
|
||||
|
||||
@@ -32,10 +33,11 @@ export default async function Page({ params: paramsPromise }: Args) {
|
||||
|
||||
export async function generateStaticParams() {
|
||||
process.env.PAYLOAD_DROP_DATABASE = 'false'
|
||||
|
||||
try {
|
||||
const pages = await getDocs<Page>('pages')
|
||||
return pages?.map(({ slug }) => slug)
|
||||
} catch (error) {
|
||||
} catch (_err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ export const getDoc = async <T>(args: {
|
||||
return docs[0] as T
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Error getting doc', err)
|
||||
throw new Error(`Error getting doc: ${err.message}`)
|
||||
}
|
||||
|
||||
throw new Error('Error getting doc')
|
||||
throw new Error('No doc found')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { type CollectionSlug, getPayload } from 'payload'
|
||||
|
||||
export const getDocs = async <T>(collection: string): Promise<T[]> => {
|
||||
export const getDocs = async <T>(collection: CollectionSlug): Promise<T[]> => {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
try {
|
||||
@@ -11,10 +11,12 @@ export const getDocs = async <T>(collection: string): Promise<T[]> => {
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
return docs as T[]
|
||||
if (docs) {
|
||||
return docs as T[]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw new Error(`Error getting docs: ${err.message}`)
|
||||
}
|
||||
|
||||
throw new Error('Error getting docs')
|
||||
throw new Error('No docs found')
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function Footer() {
|
||||
<img
|
||||
alt="Payload Logo"
|
||||
className={classes.logo}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
</picture>
|
||||
</Link>
|
||||
@@ -30,7 +30,7 @@ export async function Footer() {
|
||||
return <CMSLink key={i} {...link} />
|
||||
})}
|
||||
<Link href="/admin">Admin</Link>
|
||||
<Link href="https://github.com/payloadcms/payload/tree/main/templates/ecommerce">
|
||||
<Link href="https://github.com/payloadcms/payload/tree/main/test/live-preview">
|
||||
Source Code
|
||||
</Link>
|
||||
<Link href="https://github.com/payloadcms/payload">Payload</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import LinkWithDefault from 'next/link.js'
|
||||
import NextLinkImport from 'next/link.js'
|
||||
import React from 'react'
|
||||
|
||||
import type { Page, Post } from '../../../../payload-types.js'
|
||||
@@ -6,7 +6,7 @@ import type { Props as ButtonProps } from '../Button/index.js'
|
||||
|
||||
import { Button } from '../Button/index.js'
|
||||
|
||||
const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default
|
||||
const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default
|
||||
|
||||
type CMSLinkType = {
|
||||
appearance?: ButtonProps['appearance']
|
||||
@@ -36,20 +36,22 @@ export const CMSLink: React.FC<CMSLinkType> = ({
|
||||
}) => {
|
||||
const href =
|
||||
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
|
||||
? `/${reference.value.slug}`
|
||||
? `/live-preview/${reference.value.slug}`
|
||||
: url
|
||||
|
||||
if (!href) return null
|
||||
if (!href) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!appearance) {
|
||||
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
|
||||
|
||||
if (href || url) {
|
||||
return (
|
||||
<Link {...newTabProps} className={className} href={href || url || ''}>
|
||||
<NextLink {...newTabProps} className={className} href={href || url || ''}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
{children || null}
|
||||
</NextLink>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-restricted-exports */
|
||||
import { notFound } from 'next/navigation.js'
|
||||
import React from 'react'
|
||||
|
||||
@@ -32,10 +33,11 @@ export default async function Page({ params: paramsPromise }: Args) {
|
||||
|
||||
export async function generateStaticParams() {
|
||||
process.env.PAYLOAD_DROP_DATABASE = 'false'
|
||||
|
||||
try {
|
||||
const pages = await getDocs<Page>('pages')
|
||||
return pages?.map(({ slug }) => slug)
|
||||
} catch (error) {
|
||||
} catch (_err) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ export const getDoc = async <T>(args: {
|
||||
return docs[0] as T
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Error getting doc', err)
|
||||
throw new Error(`Error getting doc: ${err.message}`)
|
||||
}
|
||||
|
||||
throw new Error('Error getting doc')
|
||||
throw new Error('No doc found')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import config from '@payload-config'
|
||||
import { getPayload } from 'payload'
|
||||
import { type CollectionSlug, getPayload } from 'payload'
|
||||
|
||||
export const getDocs = async <T>(collection: string): Promise<T[]> => {
|
||||
export const getDocs = async <T>(collection: CollectionSlug): Promise<T[]> => {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
try {
|
||||
@@ -11,10 +11,12 @@ export const getDocs = async <T>(collection: string): Promise<T[]> => {
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
return docs as T[]
|
||||
if (docs) {
|
||||
return docs as T[]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw new Error(`Error getting docs: ${err.message}`)
|
||||
}
|
||||
|
||||
throw new Error('Error getting docs')
|
||||
throw new Error('No docs found')
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function Footer() {
|
||||
<img
|
||||
alt="Payload Logo"
|
||||
className={classes.logo}
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/payload/src/admin/assets/images/payload-logo-light.svg"
|
||||
src="https://raw.githubusercontent.com/payloadcms/payload/main/packages/ui/src/assets/payload-logo-light.svg"
|
||||
/>
|
||||
</picture>
|
||||
</Link>
|
||||
@@ -30,7 +30,7 @@ export async function Footer() {
|
||||
return <CMSLink key={i} {...link} />
|
||||
})}
|
||||
<Link href="/admin">Admin</Link>
|
||||
<Link href="https://github.com/payloadcms/payload/tree/main/templates/ecommerce">
|
||||
<Link href="https://github.com/payloadcms/payload/tree/main/test/live-preview">
|
||||
Source Code
|
||||
</Link>
|
||||
<Link href="https://github.com/payloadcms/payload">Payload</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import LinkWithDefault from 'next/link.js'
|
||||
import NextLinkImport from 'next/link.js'
|
||||
import React from 'react'
|
||||
|
||||
import type { Page, Post } from '../../../../../payload-types.js'
|
||||
@@ -6,7 +6,7 @@ import type { Props as ButtonProps } from '../Button/index.js'
|
||||
|
||||
import { Button } from '../Button/index.js'
|
||||
|
||||
const Link = (LinkWithDefault.default || LinkWithDefault) as typeof LinkWithDefault.default
|
||||
const NextLink = (NextLinkImport.default || NextLinkImport) as typeof NextLinkImport.default
|
||||
|
||||
type CMSLinkType = {
|
||||
appearance?: ButtonProps['appearance']
|
||||
@@ -36,20 +36,22 @@ export const CMSLink: React.FC<CMSLinkType> = ({
|
||||
}) => {
|
||||
const href =
|
||||
type === 'reference' && typeof reference?.value === 'object' && reference.value.slug
|
||||
? `/${reference.value.slug}`
|
||||
? `/live-preview/${reference.value.slug}`
|
||||
: url
|
||||
|
||||
if (!href) return null
|
||||
if (!href) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!appearance) {
|
||||
const newTabProps = newTab ? { rel: 'noopener noreferrer', target: '_blank' } : {}
|
||||
|
||||
if (href || url) {
|
||||
return (
|
||||
<Link {...newTabProps} className={className} href={href || url || ''}>
|
||||
<NextLink {...newTabProps} className={className} href={href || url || ''}>
|
||||
{label && label}
|
||||
{children && children}
|
||||
</Link>
|
||||
{children || null}
|
||||
</NextLink>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { postsSlug } from '../shared.js'
|
||||
|
||||
export const postsPage: Partial<Page> = {
|
||||
title: 'Posts',
|
||||
slug: 'live-preview/posts',
|
||||
slug: 'posts',
|
||||
meta: {
|
||||
title: 'Payload Website Template',
|
||||
description: 'An open-source website built with Payload and Next.js.',
|
||||
|
||||
@@ -5,7 +5,7 @@ export const formatLivePreviewURL: LivePreviewConfig['url'] = async ({
|
||||
collectionConfig,
|
||||
payload,
|
||||
}) => {
|
||||
let baseURL = 'http://localhost:3000/live-preview'
|
||||
let baseURL = '/live-preview'
|
||||
|
||||
// You can run async requests here, if needed
|
||||
// For example, multi-tenant apps may need to lookup additional data
|
||||
@@ -25,6 +25,7 @@ export const formatLivePreviewURL: LivePreviewConfig['url'] = async ({
|
||||
.then((res) => res?.docs?.[0])
|
||||
|
||||
if (fullTenant?.clientURL) {
|
||||
// Note: appending a fully-qualified URL here won't work for preview deployments on Vercel
|
||||
baseURL = `${fullTenant.clientURL}/live-preview`
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user