Skip to content
← All writing

Using 3D Models in Gatsby

I ran into an interesting problem while trying to host a small graphics project on my Gatsby website.

This describes the Gatsby 5 and Three.js r171 setup I was using in December 2024.

The problem

For context, I’m using Three.js to render a snow globe on a web canvas. Writing the snow globe itself was relatively easy. Getting Gatsby to handle the assets the way I wanted was the harder part.

I export the model in GLTF format, which consists of a primary .gltf file and some associated files—in my case, a .bin file. The Three.js loader takes the URL of the .gltf file and resolves those other paths relative to it.

The problem is that Gatsby’s static asset pipeline adds a hash to each file’s name. For example, model.bin might become model-<hash>.bin. This is useful for cache invalidation, but it breaks the relative path stored in the GLTF file.

More generally, assets that refer to neighboring files need to be versioned and moved as a group. Renaming them independently breaks those internal references.

The solution

Here’s the solution I came up with:

  1. Use a folder named assets to tell Gatsby to treat these files differently.
  2. Generate one hash for the entire folder.
  3. Copy the assets without changing their individual names.
  4. Store the folder under a path that includes the hash.

Here’s how that looks in my gatsby-node.ts config:

async function scanForAssetFolders(
  baseDir: string
): Promise<{[path: string]: AssetFolderInfo}> {
  const foldersToScan = [baseDir];
  const assetFolders: {[path: string]: AssetFolderInfo} = {};
  while (foldersToScan.length > 0) {
    const folder = foldersToScan.pop()!;
    for (const filename of await fs.readdir(folder)) {
      const filepath = path.resolve(folder, filename);
      const filestat = await fs.stat(filepath);
      if (!filestat.isDirectory()) {
        continue;
      }
      if (filename === 'assets') {
        const mtimeStr = filestat.mtime.getSeconds().toString();
        const hash = crypto.createHash('md5')
          .update(filepath)
          .update(mtimeStr)
          .digest('hex');
        assetFolders[filepath] = {hash};
        continue;
      }
      foldersToScan.push(filepath);
    }
  }
  return assetFolders;
}

export const onCreateWebpackConfig: GatsbyNode["onCreateWebpackConfig"] = async ({
  actions,
  getConfig
}) => {
  const config = getConfig();
  const assetFolders = await scanForAssetFolders(path.resolve('src'));
  for (const rule of config.module.rules) {
    rule.exclude = /\/assets\//;   }
  config.module.rules.push({
    test: /\/assets\//,
    use: {
      loader: 'file-loader',
      options: {
        outputPath: (url: string, resourcePath: string) => {
          const splitIdx = resourcePath.lastIndexOf('/assets/') + 7;
          const assetFolderPath = resourcePath.slice(0, splitIdx);
          const assetFilePath = resourcePath.slice(splitIdx + 1);
          const hash = assetFolders[assetFolderPath].hash;
          return path.join('assets', hash, assetFilePath);        }
      }
    }
  });
actions.replaceWebpackConfig(config);
};

The result

The GLTF and .bin file now stay together, while the parent folder still gets a new path when the assets change. So far, this works well!

© 2026 Eugene Che