I'm trying to build a custom component for Contentful's live preview feature in Next 13 and the app directory. My idea is to create a client component which accepts a data prop, type-safe by allowing a generic type to be passed down. Like this:
LivePreviewWrapper.tsx
'use client'
import { useContentfulLiveUpdates } from '@contentful/live-preview/react'
const isFunction = <T extends CallableFunction = CallableFunction>(value: unknown): value is T =>
typeof value === 'function'
export const runIfFunction = <T, U>(valueOrFn: T | ((...fnArgs: U[]) => T), ...args: U[]) => {
return isFunction(valueOrFn) ? valueOrFn(...args) : valueOrFn
}
type MaybeRenderProp<P> = React.ReactNode | ((props: P) => React.ReactNode)
type LivePreviewWrapperProps<T> = {
children: MaybeRenderProp<{
updatedData: T
}>
data: T
}
export const LivePreviewWrapper = <T extends Record<string, unknown>>({
children,
data
}: LivePreviewWrapperProps<T>) => {
const updatedData = useContentfulLiveUpdates<T>(data, { locale: 'en-US' })
return runIfFunction(children, { updatedData })
}
I can then pull this into a page component, pass the data I pull from Contentful down, include the type, and then get the live updates. But, when I try and run it this way in a page component, I'm getting this error:
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
<... data={{...}} children={function}>
^^^^^^^^^^
This is what my page component looks like:
import { notFound } from 'next/navigation'
import { ContentfulCta, type ContentfulCtaProps } from '~/components/contentful/cta'
import { ContentfulGrid, type ContentfulGridProps } from '~/components/contentful/grid'
import { ContentfulHero, type ContentfulHeroProps } from '~/components/contentful/hero'
import { ContentfulHighlight, type ContentfulHighlightProps } from '~/components/contentful/highlight'
import { ContentfulHorizontalTabs, type ContentfulHorizontalTabsProps } from '~/components/contentful/horizontal-tabs'
import { ContentfulSpeaker, type ContentfulSpeakerProps } from '~/components/contentful/speaker'
import { ContentfulTextSession, type ContentfulTextSessionProps } from '~/components/contentful/text'
import { LogoList } from '~/components/marketing/logo-list'
import { LivePreviewWrapper } from '~/components/shared/live-preview-wrapper'
import { getContentfulParams, getPageBySlug, type ContentfulPage } from '~/models/contentful'
import { ContentfulProvider } from '~/providers/contentful'
import { type TypePageSkeleton } from '~/types/generated/contentful'
import { type PageParamsWithSearch } from '~/types/helpers'
import { formatParamsSlug } from '~/utils/core'
import '@contentful/live-preview/style.css'
type LandingPageParams = PageParamsWithSearch<{ slug: string[] }>
export const generateStaticParams = async () => {
const pages = await getContentfulParams<TypePageSkeleton>('page')
return pages.items.map((page) => ({
params: {
slug: formatParamsSlug(page.fields.slug)
}
}))
}
export const revalidate = 60
export const dynamic = 'force-dynamic'
export default async function ContentfulLandingPage({ params, searchParams }: LandingPageParams) {
const isDraftMode = searchParams?.draftMode === 'enabled'
const data = await getPageBySlug({ slug: params.slug, isDraftMode })
if (!data) {
notFound()
}
return (
<ContentfulProvider>
<main className='pb-8 lg:pb-12'>
<LivePreviewWrapper<ContentfulPage> data={data}>
{({ updatedData }) => {
return (
<div className='space-y-8'>
{updatedData.fields.body.map((entry) => {
if (!entry) return null
switch (entry.sys.contentType.sys.id) {
case 'hero': {
return <ContentfulHero key={entry.sys.id} entry={entry as ContentfulHeroProps} />
}
case 'highlightSession': {
return <ContentfulHighlight key={entry.sys.id} entry={entry as ContentfulHighlightProps} />
}
case 'grid': {
return <ContentfulGrid key={entry.sys.id} entry={entry as ContentfulGridProps} />
}
case 'textSession': {
return <ContentfulTextSession key={entry.sys.id} entry={entry as ContentfulTextSessionProps} />
}
case 'speaker': {
return <ContentfulSpeaker key={entry.sys.id} entry={entry as ContentfulSpeakerProps} />
}
case 'cta': {
return <ContentfulCta key={entry.sys.id} entry={entry as ContentfulCtaProps} />
}
case 'horizontalTabs': {
return (
<ContentfulHorizontalTabs key={entry.sys.id} entry={entry as ContentfulHorizontalTabsProps} />
)
}
case 'customersLogos': {
return <LogoList key={entry.sys.id} />
}
default:
console.warn(entry.sys.contentType, 'was not handled')
return null
}
})}
</div>
)
}}
</LivePreviewWrapper>
</main>
</ContentfulProvider>
)
}
I don't understand why this error is being returned; does anyone have a better idea how to fix it, I don't know what else to try. It seems to be something with the useContentfulLiveUpdates
hook, but the type of updatedData
is an object.
Functions are not serialisable in Next.js at this stage. Render props work RSC -> RSC or RCC -> RCC but not RSC -> RCC yet it seems. In this case, it looks to me that the compiler could easily be made to be smart enough to handle this case but I think Next.js 13 app dir and RSC need some time to mature.
It would appear to me that creating an intermediary client component would work.
"use client"
// ...
export const MyLivePreview = ({ data }: { data: WhateverDataType }) => <LivePreviewWrapper<ContentfulPage> data={data}>
{({ updatedData }) => {
return (
<div className='space-y-8'>
{updatedData.fields.body.map((entry) => {
if (!entry) return null
switch (entry.sys.contentType.sys.id) {
case 'hero': {
return <ContentfulHero key={entry.sys.id} entry={entry as ContentfulHeroProps} />
}
case 'highlightSession': {
return <ContentfulHighlight key={entry.sys.id} entry={entry as ContentfulHighlightProps} />
}
case 'grid': {
return <ContentfulGrid key={entry.sys.id} entry={entry as ContentfulGridProps} />
}
case 'textSession': {
return <ContentfulTextSession key={entry.sys.id} entry={entry as ContentfulTextSessionProps} />
}
case 'speaker': {
return <ContentfulSpeaker key={entry.sys.id} entry={entry as ContentfulSpeakerProps} />
}
case 'cta': {
return <ContentfulCta key={entry.sys.id} entry={entry as ContentfulCtaProps} />
}
case 'horizontalTabs': {
return (
<ContentfulHorizontalTabs key={entry.sys.id} entry={entry as ContentfulHorizontalTabsProps} />
)
}
case 'customersLogos': {
return <LogoList key={entry.sys.id} />
}
default:
console.warn(entry.sys.contentType, 'was not handled')
return null
}
})}
</div>
)
}}
</LivePreviewWrapper>
Then,
export default async function ContentfulLandingPage({ params, searchParams }: LandingPageParams) {
const isDraftMode = searchParams?.draftMode === 'enabled'
const data = await getPageBySlug({ slug: params.slug, isDraftMode })
if (!data) {
notFound()
}
return (
<ContentfulProvider>
<main className='pb-8 lg:pb-12'>
<MyLivePreview data={data} />
</main>
</ContentfulProvider>
)
}
Annoyingly, for my experience using Next.js 13 app dir, a lot of code splitting is required. I would imagine the compiler will become smarter to know what server and what's client.
Let me know if this helps (noting that I did not actually test any of this). Otherwise will be happy to help to diagnose the issue.