Importing JSON Modules with Import Attributes (ES2025): from 'with' to Runtime Support

Last updated:

Import attributes is the ES2025 feature that finally gives JavaScript a standard way to import JSON files: import data from './data.json' with { type: 'json' }. The proposal reached TC39 Stage 4 in September 2024 and is part of the ES2025 spec. It replaces the earlier assert keyword (which was deprecated and removed) with a redesigned with form that is part of module loading rather than a post-load check. Runtime support landed across Chrome 123, Firefox 127, Safari 17.5, and Node.js 23 — all within a single year. TypeScript 5.3+ understands the syntax, and every major bundler accepts it. This guide covers the syntax, the version matrix, the migration from assert, and the gotchas that catch teams in the transition.

About to import a JSON file with attributes? Run the file through Jsonic's JSON Validator first — a single trailing comma or unquoted key turns your import into a runtime error with no useful stack trace.

Validate JSON before importing

Import attributes (ES2025): the 'with' keyword replacing 'assert'

The import attributes syntax adds a with clause to import statements that carries key-value metadata about the module being loaded. For JSON, the attribute is type: 'json', which tells the host runtime to load the resource as parsed JSON rather than as an executable JavaScript module.

// Static import — works in modern Node.js, browsers, and bundlers
import config from './config.json' with { type: 'json' }
import { version } from './package.json' with { type: 'json' }

console.log(config.apiUrl)
console.log(version)

The with clause goes after the module specifier (the string), not after the imported binding. The attribute object accepts string keys mapping to string values. As of ES2025 the only standardized attribute is type, with 'json' as the registered value. Hosts may define their own additional attributes for non-standard module types (CSS, WebAssembly, etc.).

The attribute is not a hint — it is part of the module loading contract. A host that sees with { type: 'json' } must refuse to execute the response as JavaScript even if the server sends an executable MIME type. This is the security property that the old assert form failed to deliver. The same syntax works for re-exports: export { default as config } from './config.json' with { type: 'json' }. See our JSON.parse guide for the programmatic alternative when you have a string rather than a file URL.

The old import assertions proposal and why it was reworked

Before import attributes, TC39 had a proposal called import assertions that used the assert keyword. The syntax looked nearly identical:

// DEPRECATED — do not use in new code
import data from './data.json' assert { type: 'json' }
import data from './data.json' assert { type: 'json' }  // assert form

The proposal reached Stage 3 in 2020 and shipped in V8, JavaScriptCore, and SpiderMonkey under that name. Then a security audit surfaced a problem. The word assert implied a runtime check applied after the module loaded — first fetch and parse the file, then verify the type matches. That model meant a malicious or misconfigured server could send executable JavaScript with a type: 'json' assertion, and the engine would execute the code before the assertion ran. The security boundary the proposal was supposed to provide did not exist.

The fix required rewriting the proposal so the attribute participates in module resolution. The renamed with form makes the attribute a precondition for loading: the host uses it to choose the parser before any code executes. The rename from assert to with reflects this semantic shift — the attribute describes how to load the module, not a check after loading. TC39 reset the proposal stage and import attributes reached Stage 4 in September 2024.

The legacy assert form is now a deprecation path. Node.js and browsers accept it with warnings during a transition window, but the spec only defines with. Treat any new assert code in 2026 as a bug.

Browser support: Chrome 123+, Firefox 127+, Safari 17.5+

Browser support for with syntax landed across all three major engines within four months in 2024. The dates matter because they define the minimum versions you can target without a bundler:

BrowserVersionReleaseNotes
Chrome (V8)123+March 2024Both static and dynamic; assert removed in 126
Edge123+March 2024Tracks Chrome
Safari (JavaScriptCore)17.5+May 2024iOS Safari 17.5+ included
Firefox (SpiderMonkey)127+June 2024Both forms; assert deprecation warning

The server must also send the right Content-Type header. A JSON file served as text/html or text/plain is refused by the browser with a network error, even with the correct attribute. Static hosts (Vercel, Netlify, Cloudflare Pages, GitHub Pages) all set application/json automatically for .json files; check your headers if you serve from a custom origin.

For older browsers, a bundler is the answer — Vite, esbuild, and webpack all parse the with syntax in source code and emit ES5- or ES2017-compatible bundles that work everywhere. The attribute is consumed at bundle time, not at runtime. If your minimum browser is below the support cutoffs, bundle. If you ship raw ESM to modern browsers (common for libraries on jsDelivr or unpkg), the attribute is required.

Node.js: 22 (LTS) with --experimental-json-modules, 23+ stable

Node.js gained import attribute support over several versions. The current state in 2026:

// package.json (required for ESM mode)
{
  "name": "my-app",
  "type": "module",
  "dependencies": {}
}

// index.mjs (or index.js with "type": "module")
import config from './config.json' with { type: 'json' }
import pkg from './package.json' with { type: 'json' }

console.log(`Running ${pkg.name} v${pkg.version}`)
console.log(`API base: ${config.apiUrl}`)
Node.js versionStatusCommand
20 LTSExperimental, assert onlynode --experimental-json-modules index.mjs
22 LTSExperimental, with supportednode --experimental-json-modules index.mjs
23Stable, no flag needednode index.mjs
24 (next LTS, April 2026)Stablenode index.mjs

The flag in Node 22 is sometimes confusing — it was originally introduced in Node 17 when the entire feature was experimental. Node 23 promoted it to stable. Node 22 will receive backports throughout its LTS window but the official advice is to upgrade to 23+ for new projects that depend on the syntax.

CommonJS continues to work as before: const data = require('./data.json') has no flags, no attributes, no version requirements. The require form is the lowest-friction option for Node-only scripts. The ESM form is what you want when your code runs in both Node and browsers (libraries, isomorphic apps). See JSON in Bun for the equivalent in the Bun runtime — Bun supports the with syntax natively without any flag.

TypeScript 5.3+: import attributes type support

TypeScript 5.3 (November 2023) added syntax and emit support for the with keyword in import statements. Before 5.3, you could use resolveJsonModule to import JSON without any attribute, but the compiler would not emit or preserve a with clause if you wrote one in source.

// tsconfig.json — minimum config for import attributes
{
  "compilerOptions": {
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "strict": true
  }
}
// data.ts
import config from './config.json' with { type: 'json' }

interface Config {
  apiUrl: string
  retries: number
  features: string[]
}

// TypeScript infers the shape from the JSON file automatically
const typedConfig: Config = config
console.log(typedConfig.apiUrl)

The resolveJsonModule option remains the practical way to get typed JSON imports — TypeScript reads the JSON file at compile time and infers a literal type from its contents. The with attribute is orthogonal: it tells the runtime how to load the module. You need both for a build that works at compile time and at runtime in modern ESM environments. See our tsconfig resolveJsonModule guide for the full option reference and our Parse JSON in TypeScript guide for programmatic typing with JSON.parse.

On the module setting: use nodenext or esnext for Node.js ESM and modern bundlers. Older settings like commonjs or es2020 emit a require call or strip the attribute. If you see your with clause disappearing from compiled output, the cause is almost always the module setting.

Bundlers: esbuild, Rollup, webpack, Vite, Turbopack

Every major bundler parses the with syntax and treats JSON imports as a first-class feature. The attribute is informational in a bundling context — the bundler already knows the file is JSON from its extension — but writing it in source keeps the code portable to non-bundled environments.

// vite.config.ts — JSON imports work out of the box
import { defineConfig } from 'vite'

export default defineConfig({
  json: {
    namedExports: true,   // allow import { key } from './file.json'
    stringify: 'auto',    // serialize large JSON as a string literal
  },
})

// webpack — JSON imports are native in webpack 5+, no loader needed
// esbuild — handles JSON imports natively; pass --loader:.json=json if customizing
// Rollup — needs @rollup/plugin-json (included in most templates)
// Turbopack (Next.js 15+) — native support for both static and dynamic imports
Bundlerwith syntaxPlugin needed?Named imports from JSON?
ViteYesNoYes (default)
esbuildYesNoNo (default export only)
RollupYes@rollup/plugin-jsonYes (plugin option)
webpack 5+YesNoYes (default)
TurbopackYesNoYes
Parcel 2YesNoYes

Vite's json.stringify: 'auto' option is worth knowing — for large JSON files it wraps the content in JSON.parse('...') at bundle time. JSON.parse is faster than parsing an equivalent object literal in V8 because the parser knows the input is plain data. This is a real performance win for config files over ~10 KB.

For Next.js specifically: Turbopack (the default bundler in Next.js 15+) handles JSON imports with attributes natively. The same works in React Server Components imports — the attribute is preserved through the RSC compiler and respected at runtime.

Dynamic import() with attributes

The dynamic import() form takes attributes through an options object as the second argument. The structure mirrors the static form but uses function-call syntax.

// Dynamic import — runtime URL, on-demand loading
async function loadConfig(env: string) {
  const module = await import(`./config.${env}.json`, {
    with: { type: 'json' }
  })
  return module.default
}

// Conditional loading based on a feature flag
async function loadFeatureSet() {
  const useNewFlags = process.env.NEW_FLAGS === 'true'
  const url = useNewFlags ? '/flags/v2.json' : '/flags/v1.json'
  const { default: flags } = await import(url, {
    with: { type: 'json' }
  })
  return flags
}

// Loading from a CDN at runtime
async function loadRemoteSchema(schemaId: string) {
  const url = `https://cdn.example.com/schemas/${schemaId}.json`
  const { default: schema } = await import(url, {
    with: { type: 'json' }
  })
  return schema
}

The options object accepts a with property holding the same key-value attributes as the static form. The whole second argument is optional — without it, the host treats the import as a regular module import (which will fail for JSON in ESM mode without an attribute). With { with: { type: 'json' } }, the host loads the URL as JSON and rejects non-JSON content.

Dynamic imports return a module namespace object — the parsed JSON is on the default property. The destructuring pattern const { default: data } = await import(...) is the idiomatic way to extract the parsed value. Top-level keys are also available as named exports when the bundler supports it (Vite, webpack), but in raw Node.js ESM only the default export is reliably populated.

Use dynamic imports for code-split JSON, lazy-loaded translations, and any case where the URL is computed at runtime. For static config baked into the bundle, the top-level import form is faster (no Promise indirection) and friendlier to tree-shaking.

Migration from import assert { type: 'json' } to with { type: 'json' }

The migration is mechanical. The two syntaxes have identical semantics for the type: 'json' case — only the keyword changed. A regex or codemod handles the vast majority of files.

# Find all files using the deprecated assert form
rg "assert\s*\{\s*type:" --type ts --type tsx --type js --type mjs

# Quick sed-based codemod (BSD sed on macOS)
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.mjs" \) \
  -not -path "./node_modules/*" \
  -exec sed -i '' -E 's/(import[^;]*)assert(\s*\{)/\1with\2/g' {} +

# GNU sed (Linux)
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.mjs" \) \
  -not -path "./node_modules/*" \
  -exec sed -i -E 's/(import[^;]*)assert(\s*\{)/\1with\2/g' {} +

# After running, verify with rg — no remaining matches expected
rg "assert\s*\{\s*type:" --type ts --type tsx --type js --type mjs

The regex matches assert only when it appears after an import keyword and before an opening brace, so it does not touch other uses of the word assert (test assertions, type assertions in TypeScript). Run the search first, eyeball the matches, then run the replace.

For dynamic imports the same edit works — the keyword assert appears in the options object as a property name in the old form ({ assert: { type: 'json' } }) and changes to with in the new form. The codemod above is regex-based and handles both static and dynamic cases.

For projects with thousands of files, prefer a real codemod tool: jscodeshift or ts-morph parses the AST and rewrites only legitimate import statements, avoiding false positives in strings or comments. The npm package @codemod/import-attributes ships a ready-made transform.

What to leave alone: CommonJS require('./data.json') calls — they were never affected by import attributes and continue working without any changes. JSON-related config files (tsconfig.json, package.json) — these are read by tools that have their own parsers and do not use the JavaScript import system. See our JSON config files guide for that ecosystem.

Key terms

import attributes
The ES2025 feature that adds a with clause to import statements, carrying key-value metadata that the host uses during module resolution. The only standardized attribute key is type, with 'json' as the registered value.
import assertions
The deprecated predecessor proposal using the assert keyword. Removed from the spec in favor of import attributes after security concerns about post-load checking. Still accepted by some runtimes with deprecation warnings.
module specifier
The string after from in an import statement — the URL, path, or package name identifying the module to load. The with clause follows the specifier.
resolveJsonModule
A TypeScript compiler option (in tsconfig.json) that lets you import JSON files and get inferred types from their contents. Independent of import attributes — the option controls compile-time type behavior, the attribute controls runtime loading.
ESM (ECMAScript Modules)
The standard JavaScript module system using import and export. JSON modules require ESM mode — CommonJS require() uses its own JSON loading and does not need attributes.
Stage 4
The final TC39 proposal stage, indicating a feature is finished and ready for inclusion in the ECMAScript spec. Import attributes reached Stage 4 in September 2024 and is part of ES2025.

Frequently asked questions

What's the difference between 'import assertions' and 'import attributes'?

Import assertions was the original TC39 proposal (using the `assert` keyword) for declaring metadata about an imported module — most commonly the module type. Import attributes is the renamed, reworked proposal that uses the `with` keyword. The two are semantically different: assertions were specified as post-load checks (parse the module, then verify the assertion), which created problems for security-sensitive cases like JSON. Import attributes are part of the module resolution and loading process — the host can use the attribute to choose how to fetch and parse the module before any code runs. This matters because a JSON import with `with { type: 'json' }` tells the host to refuse to execute the file as JavaScript even if the server sends an executable MIME type. The syntax change from `assert` to `with` reflects this redesign. Import attributes reached Stage 4 at TC39 in September 2024 and shipped as part of ES2025.

Why was assert deprecated in favor of with?

Three reasons drove the change. First, security: assertions were checks applied after a module was already loaded as JavaScript, which meant a JSON import could still execute arbitrary code if the file did not match. The `with` form is part of the loading contract — the host uses it to choose the correct parser before execution. Second, semantics: the word `assert` implied a runtime check that could pass or fail, but the actual behavior was a host hint that changed how the import resolved. `with` reads naturally as 'load this module with these attributes,' which matches what the engine actually does. Third, future-proofing: `with` is designed to carry other attributes beyond `type` — content security policies, integrity hashes, and version pinning are all on the proposal roadmap. The old `assert` keyword would have been awkward for those. Node.js 22 and modern browsers emit deprecation warnings for `assert` and the spec was finalized with `with` as the only supported form.

Which Node.js version supports import attributes for JSON?

Node.js 22 (the current LTS) supports `with { type: 'json' }` behind the `--experimental-json-modules` flag, and also accepts the legacy `assert` form with a deprecation warning. Node.js 23 (released October 2024) made JSON modules stable — no flag needed — and is the first version where you can ship `import data from './data.json' with { type: 'json' }` in production without runtime flags. Node.js 24 (the next LTS, expected April 2026) keeps the same behavior. If you are targeting Node 22, either keep the flag in your start script or upgrade to 23+ before relying on the syntax. The flag has been in Node since version 17 — the change in 23 is that it is no longer experimental. Worth noting: the new behavior requires ESM (`"type": "module"` in package.json or `.mjs` extensions). CommonJS `require('./data.json')` continues to work as it always has, with no flags or attributes.

Can I import JSON in TypeScript without import attributes?

Yes, with the `resolveJsonModule` compiler option in tsconfig.json. Set `"resolveJsonModule": true` and TypeScript lets you write `import data from './data.json'` with no attribute — the compiler infers the types from the JSON file's shape and emits the import as-is. This works because TypeScript treats JSON imports as a compile-time feature and assumes the runtime (bundler, Node with `--experimental-json-modules`, or browser via a bundler) handles the actual loading. The catch: if your target runtime requires the `with { type: 'json' }` attribute and TypeScript does not emit one, the import fails at runtime. TypeScript 5.3 added support for emitting and preserving import attributes — set `"module": "esnext"` or `"nodenext"` and the compiler will pass the attribute through. Modern bundlers (Vite, esbuild, Rollup, webpack 5+) handle JSON imports with or without the attribute. Bare Node.js ESM in v23+ wants the attribute. See our tsconfig resolveJsonModule guide for the full matrix.

Does dynamic import() support import attributes?

Yes — pass an options object as the second argument to `import()`. The shape is `await import('./data.json', { with: { type: 'json' } })`. The dynamic form uses the same `with` keyword inside an options bag rather than as standalone syntax, because dynamic import is a function call and needs structured arguments. The options object is the second positional argument; the `with` property holds the same key-value attributes you would use in the static form. Dynamic import attributes shipped in the same browser and Node versions as static import attributes — Chrome 123, Firefox 127, Safari 17.5, Node 23+. They are supported in all major bundlers. One quirk: TypeScript types for `import()` accept the options object only in TS 5.3+; on older TS versions you need a type cast or a triple-slash directive. Dynamic imports with attributes are useful for conditional loading — a feature flag that decides whether to fetch a JSON config from a CDN or fall back to a bundled file.

Why does my browser throw 'SyntaxError' on 'with' import?

Three common causes. First, browser version: only Chrome 123+ (March 2024), Firefox 127+ (June 2024), and Safari 17.5+ (May 2024) parse the `with` syntax natively. Older versions throw a SyntaxError at parse time — the entire script fails before any code runs. Check `navigator.userAgent` and your minimum supported browser list. Second, server MIME type: even with a supporting browser, the server must send the JSON file with `Content-Type: application/json`. If it serves `text/html` or `text/plain`, the browser refuses the import with a different error (TypeError or NetworkError, depending on the browser). Third, syntax mistakes: the attributes object goes after the source string, not after the import specifier — `import x from 'url' with { type: 'json' }` is correct, while `import x with { type: 'json' } from 'url'` is a syntax error. If you need to support older browsers, a bundler is the answer: Vite, esbuild, and Rollup all parse the `with` syntax and emit code that works in any browser.

What happens to the old 'assert' syntax — does it still work?

It works in most runtimes with a deprecation warning, but it is on a removal timeline. Node.js 22 accepts `assert { type: 'json' }` and prints a DeprecationWarning to stderr; the warning includes a pointer to the new `with` syntax. Node.js 23 keeps the same behavior — `assert` works but is deprecated. The plan is for `assert` support to be removed in Node 24 or 25. Chrome 123+ kept `assert` parsing for two releases for backward compatibility, then removed it; current Chrome rejects `assert` with a SyntaxError. Firefox and Safari followed similar timelines. TypeScript 5.3+ emits the `with` syntax by default but accepts `assert` in source code with a deprecation diagnostic. Most bundlers (esbuild, Rollup, Vite, webpack) accept either form in source and emit the modern `with` form in output. The safe move is to migrate now — a simple codemod (regex replace) covers the vast majority of cases. See the migration section below.

How do bundlers handle JSON imports today?

Every major bundler treats JSON imports as a first-class feature with or without the attribute, but the implementation details vary. Vite imports JSON as an ESM module by default — `import data from './data.json'` returns the parsed object, and named imports work for top-level keys (`import { version } from './package.json'`). esbuild does the same and respects the `with { type: 'json' }` attribute when present. Rollup uses the `@rollup/plugin-json` plugin (included by default in most templates) to convert JSON files into ESM modules at bundle time. webpack 5+ handles JSON natively without a loader. Turbopack (Next.js 15+) supports both static and dynamic JSON imports with attributes. The practical difference: in a bundler, the attribute is informational — the bundler already knows the file is JSON from the extension. In a raw browser or Node.js ESM environment, the attribute is required for security. Always write the attribute in source code so it works in both modes; bundlers ignore it harmlessly when bundling.

Further reading and primary sources