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.

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.

Here’s why:

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.

So, what can we do?

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 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 CheRSS