Preview URL not working with client-side rendering

I have 2 separate implementations of the preview URL. One is working and the other is not.

Here is the one that is working…

Full preview URL: http://localhost:8008/landing-page/landing-page-a

Here is the one that is not working…

Full preview URL: https://localhost:4200/#/affiliate/landing-page/64/preview

The difference between the two implementations is that the localhost:8008 implementation is NextJS which is rendering pages server-side (working) and the localhost:4200 implementation is a CRA app which is rendering pages client-side.

So it seems that the preview URL will not work with client-side rendering. I realize that there is a lot of documentation concerning getting the preview URL working ( Getting the Preview URL Working ) and I went through this list carefully but I did not see any restriction regarding client-side rendering.

Here is the relevant code from the non-working example…

import React, { Dispatch, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { BuilderContent, BuilderComponent, useIsPreviewing } from "@builder.io/react";
import { builder } from "@builder.io/sdk";
import { RenderBuilderContent } from "./builder";
import { LandingPageModel } from "../../../model/LandingPageModel";
import { selectLandingPage } from "../../../../redux/affiliate/affiliate.selectors";
import {
  getLandingPage,
  getLandingPageById,
} from "../../../../redux/affiliate/affiliate.actions";
import { useParams } from "react-router-dom";
import { TemplateBindingModel } from "../../../model/TemplateBindingModel";
import { LandingPageComponentBindingModel } from "../../../model/LandingPageComponentBindingModel";

interface LandingPagePreviewProps {
  templateBindings?: TemplateBindingModel;
}

const LandingPagePreview: React.FunctionComponent<LandingPagePreviewProps> = ({
  templateBindings,
}) => {
  const dispatch: Dispatch<any> = useDispatch();
  const { id } = useParams<{ id: string }>();
  const builderModelName = "page";
  const isPreviewingInBuilder = useIsPreviewing();
  const [notFound, setNotFound] = useState(false);
  const [content, setContent] = useState<BuilderContent<any>>()
  const [landingPage, setLandingPage] = useState<any>(null);
  useEffect(() => {
    dispatch(getLandingPageById(Number(id)));
  }, [id]);

  const selectedLandingPage: LandingPageModel = useSelector(selectLandingPage);

  // Builder Public API Key set in .env file
  builder.init(process.env.REACT_APP_BUILDER_IO_PUBLIC_API_KEY!);

  useEffect(() => {
    async function fetchContent() {
    const content = await builder
      .get(builderModelName, {
        userAttributes: {
          urlPath: '/' + selectedLandingPage?.name,
        },
      }).promise();
      setContent(content);
      setNotFound(!content);
    }
    console.log('fetching content for', selectedLandingPage?.name);
    fetchContent();
    console.log('fetched content:', content);
  }, [selectedLandingPage]);
    // if (notFound && !isPreviewingInBuilder) {
    //   return null;
    // }
    console.log('rendering preview')
    console.log(builderModelName)
    console.log(content)
    return (
        <div>
          <RenderBuilderContent content={content} model={builderModelName} />
        </div>
    );
}
export default LandingPagePreview;

Note that if I open a new browser window and paste the preview URL it will work just fine. So nothing wrong with this implementation. Note also, that in the screenshot for the non-working preview URL, my custom components have been registered, so it is clear that the preview URL is working to some extent.

If I look at the console log for the the non-working preview, I see…

So the content cannot be pulled. On the working version the content is pulled, but server-side.

I would really like to get the non-working preview URL working. But I don’t think it is possible. Any ideas?

Hello @AQuirky

Thank you for the detailed write-up and for sharing the code examples — that helps a lot in understanding the issue.

You are correct that the difference comes down to how the two implementations handle routing:

  • Next.js (working) → Server-side rendering allows the preview URL path to be resolved immediately, so Builder can fetch the correct entry by urlPath.

  • CRA with hash routing (not working) → Since the route exists only after the client-side app boots and processes the hash fragment (/#/...), Builder’s SDK is not able to resolve the content in time during preview.

That said, it is possible to support previews even in a client-side rendered setup. The key is that Builder automatically appends an entry ID (builder.preview=...) to the preview URL. You can use this to fetch the content directly in preview mode, instead of relying on urlPath.

useEffect(() => {
  async function fetchContent() {
    if (isPreviewingInBuilder) {
      // In preview, fetch directly by entry ID from query string
      const urlParams = new URLSearchParams(window.location.search);
      const contentId = urlParams.get('builder.preview');
      if (contentId) {
        const previewContent = await builder
          .get(builderModelName, { entry: contentId })
          .promise();
        setContent(previewContent);
        setNotFound(!previewContent);
        return;
      }
    }

    // Default runtime fetch (by urlPath)
    const runtimeContent = await builder.get(builderModelName, {
      userAttributes: {
        urlPath: '/' + selectedLandingPage?.name,
      },
    }).promise();
    setContent(runtimeContent);
    setNotFound(!runtimeContent);
  }

  fetchContent();
}, [selectedLandingPage, isPreviewingInBuilder]);

This allows you to keep your CRA + hash routing setup while still supporting live preview functionality in Builder.

Let us know if above solution works for you!

Thanks,

No! Gosh I got excited when I saw your answer…a glimmer of hope in a long dark passage!

I didn’t realize you could fetch content with the content id! The “entry” option is missing entirely from the content API documentation page ( Content API ).

I am storing the content ID in the database record for each landing page, which I get when I create the page with the with Write API. So my code is a little different than yours…

  useEffect(() => {
    async function fetchContent(contentId) {
      if(!isLoading && !content) {
        setIsLoading(true);
        console.log('Fetching content for ID:', contentId);
        const content0 = await builder
          .get(builderModelName, {
            entry: contentId
          }).promise().catch((error) => {
            console.error('Error fetching content:', error);
            setIsLoading(false);
          });
        setContent(content0);
        setNotFound(!content0);
        console.log('Fetched content:', content0);
        setIsLoading(false);
      }
    }
    if(selectedLandingPage){
      fetchContent(selectedLandingPage.content_id);
    }
  }, [selectedLandingPage]);

Using the content ID to fetch the content is much more robust than using the URL. Why is this a hidden option?

So when I use this code in my CRA app the preview URL again works standalone but not in the visual editor. In the console it appears that the fetch is started but never finishes. Here are the two console logs…

For the visual editor…

For the standalone preview…

So thanks for the help…let me descend now into my long dark passage.