Fix RSC build break: keep next/headers out of lib/payload.ts

payload.ts's mapping functions/types are also imported by "use client"
components (LiveTestimonialsGrid, LivePostContent) — importing
next/headers anywhere in that module made it unbundlable for the client,
breaking the production build. draftMode() is now only ever called in the
Server Component pages themselves; they pass the resulting boolean into
getPostBySlug/getLegalPage/getTestimonials as a plain `draft` option.
This commit is contained in:
Marco
2026-07-21 19:21:10 +00:00
parent 26ae4a15f4
commit 4f2f137b27
9 changed files with 30 additions and 26 deletions
+2 -2
View File
@@ -17,9 +17,9 @@ export const metadata: Metadata = {
};
export default async function AgbPage() {
const page = await getLegalPage("agb");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("agb", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
return (
<>
+2 -2
View File
@@ -39,7 +39,8 @@ export default async function BlogDetailPage({
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
const { isEnabled: isPreview } = await draftMode();
const post = await getPostBySlug(slug, { draft: isPreview });
if (!post) notFound();
// "Weiterlesen" — any other post, most recent first. Not the current
@@ -47,7 +48,6 @@ export default async function BlogDetailPage({
// showing a fake/duplicate card when this is the only post.
const otherPosts = await getBlogPosts(4);
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
const { isEnabled: isPreview } = await draftMode();
return (
<>
+1 -1
View File
@@ -169,8 +169,8 @@ function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: str
}
export default async function ChallengePage() {
const testimonials = await getTestimonials("challenge");
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("challenge", { draft: isPreview });
return (
<>
+2 -2
View File
@@ -16,9 +16,9 @@ export const metadata: Metadata = {
};
export default async function DatenschutzPage() {
const page = await getLegalPage("datenschutz");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("datenschutz", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
return (
<>
+2 -2
View File
@@ -16,9 +16,9 @@ export const metadata: Metadata = {
};
export default async function ImpressumPage() {
const page = await getLegalPage("impressum");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("impressum", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
return (
<>
+17 -13
View File
@@ -1,16 +1,20 @@
import { draftMode } from "next/headers";
import { formatPrice } from "./format";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
// Used only by the fetchers backing Live Preview (posts, legal pages,
// testimonials) — Draft Mode is enabled exclusively while a document is
// open in the Payload admin's Live Preview iframe, so bypassing the normal
// 60s ISR cache there doesn't affect ordinary site visitors at all.
async function livePreviewCacheOption(): Promise<{ cache: "no-store" } | { next: { revalidate: 60 } }> {
const { isEnabled } = await draftMode();
return isEnabled ? { cache: "no-store" } : { next: { revalidate: 60 } };
// testimonials) — callers pass `draft: true` only while Draft Mode is
// enabled (a document open in the Payload admin's Live Preview iframe),
// bypassing the normal 60s ISR cache there without affecting ordinary
// visitors. Deliberately does NOT call next/headers' draftMode() itself
// here — this module's mapping functions/types (mapPayloadTestimonial,
// mapPayloadPost, etc.) are also imported by "use client" components
// (LiveTestimonialsGrid.tsx, LivePostContent.tsx), and next/headers is a
// server-only import that breaks the client bundle the moment anything
// in this file touches it, even indirectly.
function livePreviewCacheOption(draft: boolean): { cache: "no-store" } | { next: { revalidate: 60 } } {
return draft ? { cache: "no-store" } : { next: { revalidate: 60 } };
}
export type BlogPost = {
@@ -119,7 +123,7 @@ export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
};
}
export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
export async function getPostBySlug(slug: string, options?: { draft?: boolean }): Promise<PostDetail | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[slug][equals]": slug,
@@ -127,7 +131,7 @@ export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, await livePreviewCacheOption());
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
if (!res.ok) {
console.error(`getPostBySlug: Payload returned ${res.status} ${res.statusText}`);
return null;
@@ -538,7 +542,7 @@ export function mapPayloadTestimonial(doc: PayloadTestimonial): Testimonial {
// hardcoded arrays kept manually in sync. The single-quote "photo band"
// testimonials on /not-found and /bestellbestaetigung are a different
// shape (no avatar/role) and are not part of this collection.
export async function getTestimonials(page: TestimonialsPage): Promise<Testimonial[]> {
export async function getTestimonials(page: TestimonialsPage, options?: { draft?: boolean }): Promise<Testimonial[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[page][equals]": page,
@@ -547,7 +551,7 @@ export async function getTestimonials(page: TestimonialsPage): Promise<Testimoni
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/testimonials?${params}`, await livePreviewCacheOption());
const res = await fetch(`${PAYLOAD_URL}/api/testimonials?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
if (!res.ok) {
console.error(`getTestimonials: Payload returned ${res.status} ${res.statusText}`);
return [];
@@ -574,7 +578,7 @@ type PayloadLegalPage = {
attachment: { url: string; title: string } | number | null;
};
export async function getLegalPage(type: LegalPageType): Promise<LegalPage | null> {
export async function getLegalPage(type: LegalPageType, options?: { draft?: boolean }): Promise<LegalPage | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[type][equals]": type,
@@ -582,7 +586,7 @@ export async function getLegalPage(type: LegalPageType): Promise<LegalPage | nul
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, await livePreviewCacheOption());
const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
if (!res.ok) {
console.error(`getLegalPage: Payload returned ${res.status} ${res.statusText}`);
return null;
+1 -1
View File
@@ -39,8 +39,8 @@ export const metadata: Metadata = {
// weekly newsletter), so this page resolves both at once instead of
// picking a new URL and leaving one of them still dangling.
export default async function NewsletterPage() {
const testimonials = await getTestimonials("newsletter");
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("newsletter", { draft: isPreview });
return (
<>
+1 -1
View File
@@ -33,8 +33,8 @@ export const metadata: Metadata = {
};
export default async function TodoCardsPage() {
const testimonials = await getTestimonials("todo-cards");
const { isEnabled: isPreview } = await draftMode();
const testimonials = await getTestimonials("todo-cards", { draft: isPreview });
return (
<>
+2 -2
View File
@@ -17,9 +17,9 @@ export const metadata: Metadata = {
};
export default async function WiderrufPage() {
const page = await getLegalPage("widerruf");
const headings = page ? extractHeadings(page.content) : [];
const { isEnabled: isPreview } = await draftMode();
const page = await getLegalPage("widerruf", { draft: isPreview });
const headings = page ? extractHeadings(page.content) : [];
return (
<>