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:
- Import the
embed/index.js
embed wrapper and output atdist/widget.js
- Resolve imports from
gatsby
to thegatsby-shim.js
file - Make React and ReactDOM externals (third-party website should have them available as globals)
- 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:
gatsby build
is executed like normalprebuild:embed
is run, executing theprepare-imports
script that makes the GraphQL query data available to the embed at a consistent locaitonbuild:embed
invokes the isolated Webpack bundling of the embed itselfpostbuild:embed
copies the output from the embed build into the Gatsbypublic
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.