Builder with Remix

Remix is a React framework that’s a bit like Next.JS, but has some important differences for integrating with Builder. I’m going to use this topic as a mix of advice and open questions in order to help get folks running with Remix + Builder.

1 Like

The first challenge I’ve run across is that Remix assumes that you load data as JSON and use that to render both the server-side and initial client-side versions.

Thus:

// app/routes/my-route.tsx
export const loader: LoaderFunction = async ({ params }) => {
  const { slug } = params;
  const page = await builder.get('page', {
    limit: 1,
    only: 'data',
    url: `/pages/${slug}`,
  })

  if (!page) {
    throw notFound({});
  }

  return { page };
}

export default Page({ page }) {
  return (
    <BuilderComponent mode="page" content={page} />
  )
}

One problem with this is that it doesn’t work when editing an unpublished Page because the builder.get call can’t see unpublished content. Given that the Builder editor UI passes request query-params like

/pages/my-unpublished-page
  ?builder.space=4adf90d4cb9980ac61c6a52b3b2ac4c3
  &builder.cachebust=true
  &builder.preview=page
  &builder.noCache=true
  &__builder_editing__=true
  &builder.overrides.page=9216ac6ed02e1b3d9854e01f0cc568e3
 
 &builder.overrides.9216ac6ed02e1b3d9854e01f0cc568e3=9216ac6ed02e1b3d9854e01f0cc568e3
  &builder.frameEditing=page

I tried this:

export const loader: LoaderFunction = async ({ request }) => {
  const { slug } = params;
  const page = await builder.get('page', {
    limit: 1,
    only: 'data',
    url: request.url,
  })
  …

but that didn’t work either.

I also tried pulling the ID out of the query-params and passing it as a query:

function getBuilderContent(request) {
  const queryParams = new URL(request.url).searchParams;
  if (queryParams.get('builder.preview')) {
    const id = queryParams.get('builder.overrides.page')
    if (id) {
      return builder.get('page', { query: { id } });
    }
  }

  const page = await builder.get('page', {
    limit: 1,
    only: 'data',
    url: request.url,
  })

but that get with a query turns into

&options.learning-hub-article.query=%7B%22id%22%3A%229216ac6ed02e1b3d9854e01f0cc568e3%22%7D

instead of

&query.id=9216ac6ed02e1b3d9854e01f0cc568e3

like the Content API docs suggest.

If you’re using @remix-run/serve, You can use an Express middleware to initialize Builder with knowledge of the SSR environment:

import { builder } from "@builder.io/sdk";
import { type RequestHandler } from "express";

const initBuilder: RequestHandler = function initBuilder(req, res, next) {
  const { BUILDER_API_KEY } = process.env;

  if (BUILDER_API_KEY == null) {
    throw new Error("BUILDER_API_KEY is required");
  }

  builder.init(BUILDER_API_KEY, builder.defaultCanTrack, req, res);
  return next();
};

app.use(initBuilder)

For these previewing URLs, that then gives

[dev:express]   'builder.editingMode': false,
[dev:express]   'builder.editingModel': null,
[dev:express]   'builder.previewingModel': 'page'

That seems to be progress, but I still get a 404 when trying to load an unpublished page in the editor. I can fix that by changing my query to

builder.get('page', {
  limit: 1,
  options: {
    includeUnpublished: builder.previewingModel === 'page',
  },
  userAttributes: {
    urlPath: `/pages/${slug}`,
  },
})

This seems to largely solve the in-editor experience for Remix.

Awesome @balleverte !! Glad to see you were able to get it working…one of our core tenants when Builder was first created that we could be implemented and integrated into just about any tech stack, so it’s always great to see that born out in the wild with other frameworks :slight_smile:

As for your previewing, you might also want to check out useIsPreviewing from our React SDK which should achieve the same goal, but I love your work around as well!

Let us know if you come across any other hiccups or questions as you build out your app !

I’m still getting a 404 because builder.get(…) is returning a Promise<null> for an unpublished page.

I was getting a 404 with this:

builder.get('page', {
  options: { includeUnpublished: true },
  url: somePathname
})

but this works for previews:

builder.get('page', {
  options: { includeUnpublished: true },
  userAttributes: { urlPath: somePathname },
})

useIsPreviewing won’t work for Remix because the loader function doesn’t run in a React context. Instead, it runs in an Express context and feeds JSON data into a SSR React context that runs later.

I would love to see something like

builder.init(key, allowTracking, request, response);
builder.isPreviewing // based on request

Or alternatively

builder.isPreviewing(request)

Or even just

builder.init(key, allowTracking, request, response);
builder.get(…) // automatically set includeUnpublished

The next problem is that a Remix app doesn’t work well in the Builder editor. Specifically, blocks such as Slot don’t show up. Compare this in-app editor experience with the fallback editor experience after it:


I believe the source of the problem is that Remix uses React to render the entire <html>, whereas most React applications use a static HTML shell and restrict React to a specific <div>. This means that when a Remix application rehydrates, it removes any elements that it doesn’t know about. In this case, it removes the <style id="react-editor-styles"> element.

This is a known issue in the Remix community, but the common workarounds don’t work very well here.

I was wrong. This isn’t a Remix issue. The problem was the 1Password extension on Chrome. (The Firefox extension causes no problems and Chrome works fine without the extension.)

The next issue is that Builder’s TypeScript types for BuilderContent do not allow it to be serialized to JSON and then deserialized, which is how Remix likes to operate.

  1. Remix server-side uses builder.get to fetch some BuilderContent
  2. Remix server-side uses the BuilderContent to render a <BuilderComponent> to HTML
  3. Remix server-side serializes the BuilderContent to JSON and embeds it in the HTML document (via LoaderFunction)
  4. Remix client-side deserializes the BuilderContent from JSON (via useLoaderData<BuilderContent>) and rehydrates the <BuilderComponent>

This works fine (in a JavaScript sense), but the serialization layer breaks the typing for BuilderContent. In particular, the serialization/deserialization causes content to lose anything that’s a Date or Function. The resulting TypeScript error:

Type 'SerializeObject<UndefinedToOptional<Article>>' is not assignable to type 'BuilderContent'.
  Types of property 'variations' are incompatible.
    Type 'SerializeObject<UndefinedToOptional<{ [id: string]: BuilderContentVariation | undefined; }>> | undefined' is not assignable to type '{ [id: string]: BuilderContentVariation | undefined; } | undefined'.
      Type 'SerializeObject<UndefinedToOptional<{ [id: string]: BuilderContentVariation | undefined; }>>' is not assignable to type '{ [id: string]: BuilderContentVariation | undefined; }'.
        'string' index signatures are incompatible.
          Type 'SerializeObject<UndefinedToOptional<BuilderContentVariation>>' is not assignable to type 'BuilderContentVariation'.
            Types of property 'data' are incompatible.
              Type 'SerializeObject<UndefinedToOptional<{ [key: string]: any; blocks?: BuilderElement[] | undefined; inputs?: Input[] | undefined; state?: { [key: string]: any; } | undefined; }>> | undefined' is not assignable to type '{ [key: string]: any; blocks?: BuilderElement[] | undefined; inputs?: Input[] | undefined; state?: { [key: string]: any; } | undefined; } | undefined'.
                Type 'SerializeObject<UndefinedToOptional<{ [key: string]: any; blocks?: BuilderElement[] | undefined; inputs?: Input[] | undefined; state?: { [key: string]: any; } | undefined; }>>' is not assignable to type '{ [key: string]: any; blocks?: BuilderElement[] | undefined; inputs?: Input[] | undefined; state?: { [key: string]: any; } | undefined; }'.
                  Types of property 'blocks' are incompatible.
                    Type 'SerializeObject<UndefinedToOptional<BuilderElement>>[] | undefined' is not assignable to type 'BuilderElement[] | undefined'.
                      Type 'SerializeObject<UndefinedToOptional<BuilderElement>>[]' is not assignable to type 'BuilderElement[]'.
                        Type 'SerializeObject<UndefinedToOptional<BuilderElement>>' is not assignable to type 'BuilderElement'.
                          Types of property 'responsiveStyles' are incompatible.
                            Type 'SerializeObject<UndefinedToOptional<{ large?: Partial<CSSStyleDeclaration> | undefined; medium?: Partial<CSSStyleDeclaration> | undefined; small?: Partial<...> | undefined; xsmall?: Partial<...> | undefined; }>> | undefined' is not assignable to type '{ large?: Partial<CSSStyleDeclaration> | undefined; medium?: Partial<CSSStyleDeclaration> | undefined; small?: Partial<...> | undefined; xsmall?: Partial<...> | undefined; } | undefined'.
                              Type 'SerializeObject<UndefinedToOptional<{ large?: Partial<CSSStyleDeclaration> | undefined; medium?: Partial<CSSStyleDeclaration> | undefined; small?: Partial<...> | undefined; xsmall?: Partial<...> | undefined; }>>' is not assignable to type '{ large?: Partial<CSSStyleDeclaration> | undefined; medium?: Partial<CSSStyleDeclaration> | undefined; small?: Partial<...> | undefined; xsmall?: Partial<...> | undefined; }'.
                                Types of property 'large' are incompatible.
                                  Type 'SerializeObject<UndefinedToOptional<Partial<CSSStyleDeclaration>>> | undefined' is not assignable to type 'Partial<CSSStyleDeclaration> | undefined'.
                                    Type 'SerializeObject<UndefinedToOptional<Partial<CSSStyleDeclaration>>>' is not assignable to type 'Partial<CSSStyleDeclaration>'.
                                      Types of property 'parentRule' are incompatible.
                                        Type 'SerializeObject<UndefinedToOptional<CSSRule>> | null | undefined' is not assignable to type 'CSSRule | null | undefined'.
                                          Type 'SerializeObject<UndefinedToOptional<CSSRule>>' is not assignable to type 'CSSRule'.
                                            Types of property 'parentStyleSheet' are incompatible.
                                              Type 'SerializeObject<UndefinedToOptional<CSSStyleSheet>> | null' is not assignable to type 'CSSStyleSheet | null'.
                                                Type 'SerializeObject<UndefinedToOptional<CSSStyleSheet>>' is missing the following properties from type 'CSSStyleSheet': addRule, deleteRule, insertRule, removeRule, and 2 more.ts(2322)

I opened [TypeScript] BuilderContent cannot be JSON-serialized · Issue #1387 · BuilderIO/builder · GitHub for this issue.

My temporary solution:

// app/routes/page/$slug.tsx
export default function MyRoute() {
  /* @ts-expect-error see https://github.com/BuilderIO/builder/issues/1387 */
  const content: BuilderContent = useLoaderData<BuilderContent>();
 
   return <BuilderComponent content={content} />
}
1 Like

@balleverte thank you for opening that issue, I will make sure to pass to dev/product team internally as well!