Native JSON modules are finally real

2 min read

For a long time we got used to writing this:

import config from "./config.json";

It looked like native JavaScript module syntax. But it wasn’t.

Your bundler stepped in at build time, read the JSON file, turned it into a JavaScript module, and made it feel native. The browser itself wasn’t treating JSON as a first-class module type, the tooling was.

That’s finally changed.

With import attributes, the platform can handle JSON modules directly. No transforms, no custom loaders. Just the runtime:

import config from "./config.json" with { type: "json" };

And dynamically:

const mod = await import("./config.json", {
  with: { type: "json" }
});

console.log(mod.default);

That with { type: "json" } clause is the key. It’s more verbose, and it can feel redundant at first.

Why do we need with { type: "json" }?

JavaScript modules execute, but JSON doesn’t. If the platform allowed this:

import data from "./data.json";

the engine would have to guess what you meant. Execute it as JavaScript? Parse it as data? Trust the file extension? Trust the server’s Content-Type header?

Guessing gets messy quickly, especially once security and cross-origin rules get involved.

By requiring:

import data from "./data.json" with { type: "json" };

we’re making a clear contract with the runtime:

Removing that guesswork is what makes this behave the same way everywhere.

What you actually get when you import JSON

When you import JSON with attributes:

import config from "./config.json" with { type: "json" };

you get a default export containing the parsed JSON. It behaves like any other ES module:

  • Parsed once
  • Cached like any other module
  • Added to the module graph
  • Shared across every place that imports it

Given:

{
  "featureFlag": true,
  "apiBase": "https://example.com"
}

you can write:

console.log(config.featureFlag); // true

No fetch() or manual JSON.parse().

In browsers, the server still needs to send Content-Type: application/json. Import attributes don’t override headers, and JSON modules follow normal CORS rules. Just like JavaScript modules.

The difference is that the platform handles it now.

Bundlers vs. native JSON modules

Here’s what we’ve historically relied on compared to what the platform now provides:

Behavior Bundler-based JSON import Native JSON module
Syntax import config from "./config.json" import config from "./config.json" with { type: "json" }
When parsing happens Build time Runtime
Requires build step Yes No
Works in raw browser ESM No Yes
Module caching Simulated by bundler Standard ESM caching
Security model Tooling-dependent Explicit type contract
Inlining into bundle Usually Not by default

With bundlers, JSON is usually converted at build time and often inlined, so there’s no runtime fetch.

With native JSON modules, the runtime fetches the file on first import. Normal HTTP caching applies.

Module caching

JSON modules follow normal ESM caching semantics. If you write:

import a from "./config.json" with { type: "json" };
import b from "./config.json" with { type: "json" };

console.log(a === b); // true

Both imports reference the same parsed object. The module is evaluated once and cached, just like any other ES module.

That’s different from calling fetch() multiple times, where caching is your responsibility.

That shared instance matters when configuration or static data needs to stay consistent.

Do we still need bundlers for JSON?

Modern browsers, Node, Deno, and Bun all support JSON modules with import attributes now, so if you’re writing native ES modules this just works:

import config from "./config.json" with { type: "json" };

No loaders, no transforms, and no bundler required.

That doesn’t make bundlers obsolete. They still provide things the platform doesn’t, like:

  • Inlining small JSON files into bundles
  • Asset hashing
  • Code splitting
  • Build-time optimization pipelines

But for importing JSON as a module, the platform has largely caught up with what tooling used to simulate.

Why this matters beyond JSON

Import attributes establish a pattern: instead of guessing what a module is based on file extensions or loader config, the importing code declares what it expects.

JSON is just the first case. Modern browsers also support CSS module scripts:

import styles from "./styles.css" with { type: "css" };

document.adoptedStyleSheets = [styles];

It makes the module system a little less dependent on build-time magic.

For years, JSON imports only felt native because our tools smoothed over the gap. Now they actually are.