Exporting an embeddable React component from a Gatsby app using Webpack

A recent project called for the header and footer—implemented as React components in Gatsby—to be embedded on third-party sites (think support documentation). Since both components rely on Gatsby’s Link component as well as its GraphQL-backed Static Query (useStaticQuery in this case), some creativity was required.

The solution: a separate Webpack configuration that would import the relevant file (e.g. the Header component) into a lightweight wrapper that included an explicit call to ReactDOM.render. To avoid a direct dependency on Gatsby, Webpack’s resolve directive was used to direct imports from Gatsby to a shim.

Here’s the Header component that needs to be embedded. Note the multiple imports from Gatsby:

// src/components/Header.jsx
import React from "react"
import { Link, useStaticQuery, graphql } from "gatsby"

const query = `…` // query fetching link data from CMS

const Header = () => {
  const { navigation } = useStaticQuery(query)
  return (
    <div>Markup here making use of navigation data.</div>
  )
}

This is the embedded widget’s index.js where we import the Header component and render it to a predetermined element on the page:

import React from "react"
import ReactDOM from "react-dom"
import Header from "components/Header"

const baseStyles = {
  fontFamily: "Helvetica, Arial, sans-serif",
  lineHeight: 1.4,
  background: theme.white,
  color: "#444",
  fontSize: 16,
  boxSizing: "border-box",
  [["& *", "& *:before", "& *:after"]]: { boxSizing: "inherit" },
}

const HeaderEmbed = () => (
  <div css={baseStyles}>
    <Header />
  </div>
)

const headerTarget = document.querySelector("[data-render='embed-header']")
if (headerTarget) {
  ReactDOM.render(<HeaderEmbed />, headerTarget)
}

Getting Data from Gatsby into the Embed Component #

Gatsby renders out the result of static queries to JSON files at build time. These files each live at public/page-data/sq/d/${queryHash}.json, where the queryHash is a murmurhash of the query text itself using a seed of “abc”. This same hash is used to retrieve the data client-side when useStaticQuery is called (Gatsby replaces the query string itself with the hash).

# hash-query.js
const {
  stripIgnoredCharacters,
} = require("graphql/utilities/stripIgnoredCharacters")
const { murmurhash } = require("babel-plugin-remove-graphql-queries/murmur")

const GATSBY_HASH_SEED = "abc"

// This must match Gatsby's internal hashing
// https://github.com/gatsbyjs/gatsby/blob/eafb8c6e346188f27cf1687d26544c582ed5952a/packages/babel-plugin-remove-graphql-queries/src/index.js#L144
function hashQuery(query) {
  return murmurhash(stripIgnoredCharacters(query), GATSBY_HASH_SEED).toString()
}

module.exports = hashQuery

To dynamically import this JSON file into the embed, the query needs to be extracted from the src/components/Header.jsx file much the same way Gatsby does. Since this usage is narrower, the solution can take a shortcut from the full static analysis that Gatsby performs and use a simple regular expression to match and extract the query. This is all performed in the extractGraphQLQuery function in the below file.

With the query text determined, it’s just a matter of computing the queryHash, reading the JSON file, and writing that data to a non-dynamic path that can be provided to the Header component when it calls useStaticQuery. This script (prepare-imports.js) will be run as a prebuild step for our embed, which is built after Gatsby has finished building:

// embed/prepare-imports.js
const fs = require("fs")
const path = require("path")
const hashQuery = require("./hash-query")

// Quick and rough routine to extract a single
// graphql tagged template literal from a JS file
function extractGraphQLQuery(sourcePath) {
  const data = fs.readFileSync(
    path.resolve(__dirname, "..", sourcePath),
    "utf8"
  )
  const match = /graphql`([^`]+)`/.exec(data)
  if (match) {
    return match[1]
  }
}

// Copy a cached graphql query result from `public`
// to the embed plugin folder and rename to ensure
// stable imports.
function prepareQueryForImport(
  name,
  sourcePath,
  targetFilename = `${name}.json`
) {
  const query = extractGraphQLQuery(sourcePath)
  const hash = hashQuery(query)

  const source = path.resolve(
    __dirname,
    "..",
    "public",
    "page-data",
    "sq",
    "d",
    `${hash}.json`
  )
  const dest = path.resolve(__dirname, "data", targetFilename)

  fs.copyFileSync(source, dest)

  return { name, hash, dest }
}

const queries = [
  prepareQueryForImport("header", "src/components/Header.js"),
  // Additional files can be added here as needed
]

// Export query data including hashes for reference in gatsby-shim.js
fs.writeFileSync(
  path.resolve(__dirname, "data", "queries.json"),
  JSON.stringify(queries)
)

This builds a file at embed/data/queries.json that looks like this:

[{
  "name": "header",
  "hash": "1287877988",
  "dest": "/absolute/path/to/example-project/embed/data/header.json"
}]

This queries.json dictionary can then be used to substitute in the correct data in the shimmed Gatsby imports:

// embed/gatsby-shim.js
// This file acts as a shim for components of Gatsby that
// are in use in shared components but need to be used outside
// of Gatsby in the embed.

import React from "react"
import hashQuery from "./hash-query"

import queries from "./data/queries.json"

// Import each query data file (as referenced in queries.json)
import headerData from "./data/header.json"

// Build a reference map between the query name (e.g. header)
// and the imported data for it.
const queryData = {
  header: headerData,
}

// Since Gatsby isn't being run, the query data actually
// gets passed to the `graphql` function, which allows us
// to compute its hash similar to how Gatsby does. The
// return value ends up being passed to `useStaticQuery`.
export const graphql = query => hashQuery(query.raw[0])

// We're using the query hash to determine which cached
// result data to return. This needs to match the return
// value from the native Gatsby function for compatibility.
export const useStaticQuery = queryHash => {
  const query = queries.find(query => query.hash === queryHash)
  if (!query) {
    throw new Error(`No matching query for ${queryHash}`)
  }
  return queryData[query.name].data
}

export const Link = ({
  activeClassName,
  activeStyle,
  getProps,
  innerRef,
  partiallyActive,
  ref,
  replace,
  to,
  ...rest
}) =>
  React.createElement("a", {
    ...rest,
    href: to,
  })

export default {}

With all of that established, Webpack can be configured with the following goals:

  1. Import the embed/index.js embed wrapper and output at dist/widget.js
  2. Resolve imports from gatsby to the gatsby-shim.js file
  3. Make React and ReactDOM externals (third-party website should have them available as globals)
  4. Use a Gatsby compatible Babel configuration

This will vary somewhat for each application, but for reference this is the webpack.config.js used in this solution:

const path = require("path")
const Dotenv = require("dotenv-webpack")

module.exports = {
  mode: "production",
  entry: "./embed/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "widget.js",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            plugins: ["@babel/plugin-proposal-optional-chaining"],
            presets: [
              "@babel/preset-env",
              "@babel/preset-react",
              "@emotion/babel-preset-css-prop",
            ],
          },
        },
      },
      {
        test: /\.svg$/,
        use: ["@svgr/webpack", "url-loader"],
      },
    ],
  },
  resolve: {
    modules: [path.resolve(__dirname, "../src"), "node_modules"],
    extensions: [".js", ".jsx", ".json"],
    alias: {
      gatsby: path.resolve(__dirname, "gatsby-shim.js"),
    },
  },
  externals: {
    react: "React",
    "react-dom": "ReactDOM",
  },
  plugins: [
    new Dotenv({
      systemvars: true,
    }),
  ],
  optimization: {
    usedExports: true,
  },
  devtool: "nosources-source-map",
}

Tying it all together #

The final piece of the puzzle is orchestrating these pieces at build time. To do that, the scripts are configured as below in package.json:

{
  "scripts": {
    "prebuild:embed": "node embed/prepare-imports.js",
    "build:embed": "webpack --config embed/webpack.config.js",
    "postbuild:embed": "cp -R embed/dist/widget.* ./public/embed/",
    "build": "npm-run-all build:gatsby build:embed",
    "build:gatsby": "gatsby build",
  }
}

Since Yarn will automatically run prebuild: and postbuild: prefixed scripts, this recipe results in the following:

  1. gatsby build is executed like normal
  2. prebuild:embed is run, executing the prepare-imports script that makes the GraphQL query data available to the embed at a consistent locaiton
  3. build:embed invokes the isolated Webpack bundling of the embed itself
  4. postbuild:embed copies the output from the embed build into the Gatsby public folder for consumption by other applications

Here’s a simple example of how this embed gets used by downstream applications:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Header Embed Widget</title>
    <style>
      body {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <!-- Header will be rendered inside this container -->
    <div data-render="embed-header"></div>

    <!--
      React and ReactDOM are dependencies; these tags will ensure
      they're available on the page, but they can be removed if compatible
      versions are already available.
    -->
    <script
      src="https://unpkg.com/react@16/umd/react.production.min.js"
      crossorigin
    ></script>
    <script
      src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"
      crossorigin
    ></script>

    <!-- This loads the embed code with all other dependencies and content -->
    <script
      src="/embed/widget.js"
      defer
      crossorigin
    ></script>
  </body>
</html>

Conclusion #

It’s not as cut and dry as a lot of the tasks React and Gatsby make easy for developers, but this solution has made it possible to reuse a fully featured Gatsby-dependent React component outside of the application without requiring any additional tooling for those implementing it.

 
26
Kudos
 
26
Kudos

Now read this

Fix ESLint crashing on rest operator: “Cannot read property ‘type’ of undefined”

This is a bit of an obscure one, but I tend to use the same dev tooling configuration (Prettier, ESLint, Babel, etc) for multiple projects so I’m likely to run into this again. I encountered this in the middle of a project after a VSCode... Continue →