Importing JSON in Node.js: require, Import Attributes, and TypeScript
How to import JSON in Node.js in 2026 - require vs import attributes vs fs.readFile, why `with { type: 'json' }` is mandatory, and how to make TypeScript agree with you.
Importing a JSON file used to be the single most boring line in a Node codebase. You wrote const config = require('./config.json') and got on with your life. Then ESM arrived, that line stopped working, and a task with no conceptual content at all became something you have to look up.
The good news is that it has settled. As of Node 22 and later, there is a proper, stable, standards-based way to import JSON in ES modules, and it is a one-liner. The bad news is that the one-liner has a mandatory piece of syntax nobody remembers, TypeScript needs convincing separately, and there is a real trade-off nobody mentions about whether you should be importing JSON at all.
The short answer
In an ES module, on Node 22 or newer:
import config from './config.json' with { type: 'json' }
console.log(config.name)That is it. The with { type: 'json' } part is called an import attribute, it is not optional, and leaving it off produces an error rather than a warning. Import attributes reached TC39 stage 4 and were marked stable in Node - they are no longer behind an experimental flag.
One detail that surprises people: a JSON module only ever exposes a default export. There are no named exports, even though the file is full of top-level keys.
// works
import pkg from './package.json' with { type: 'json' }
console.log(pkg.version)
// does NOT work - SyntaxError
// import { version } from './package.json' with { type: 'json' }Why the attribute is mandatory
This looks like bureaucracy until you know what it is defending against. Without the attribute, the runtime would have to decide how to interpret a module based on its file extension or, worse, on the content type a server sent back. That is a security problem: a server could serve something that looks like JSON on one request and JavaScript on the next, and the importing code would have executed it.
By making the expected type part of the import statement itself, the check moves to the consumer. You declare what you are expecting; if the module is not that, the import fails rather than silently executing. The same mechanism is what powers CSS module imports in browsers.
This is also why the syntax changed once during standardisation - you may still find older articles and Stack Overflow answers using assert { type: 'json' } instead of with. The assert keyword was the earlier proposal spelling and is deprecated. Use with.
Dynamic import, and the case for it
The dynamic form takes the attributes in an options object, and note the doubled with - one for the option name, one for the attribute bag:
const data = await import('./data.json', { with: { type: 'json' } })
console.log(data.default.items.length)The extra .default catches everyone at least once. A dynamic import resolves to the module namespace object, not to the default export, so the payload is one level deeper than with a static import.
Dynamic import is the right tool when the path is computed at runtime - loading a locale file, a theme, a plugin manifest. If the path is a literal, prefer the static form: it is analysable by bundlers and it fails at load time rather than halfway through a request.
CommonJS is still fine
If your file is CommonJS, nothing has changed and nothing needs to. require of a .json file has worked since forever, returns the parsed object directly, and caches it.
const config = require('./config.json')There is no reason to migrate a working CommonJS file to ESM purely to modernise a JSON import. The upgrade pressure should come from somewhere else - top-level await, or a dependency that ships ESM only.
The TypeScript half of the problem
A large share of searches for import type json are really people whose runtime is happy and whose compiler is not. TypeScript has its own gate: the resolveJsonModule compiler option. With it off, TypeScript does not consider a .json file to be a module at all and will tell you it cannot find one.
{
"compilerOptions": {
"resolveJsonModule": true,
"module": "nodenext",
"moduleResolution": "nodenext"
}
}With resolveJsonModule enabled, TypeScript reads the JSON file at compile time and infers a structural type from its literal contents. That is genuinely useful - you get autocompletion on the keys for free - but it has a sharp edge worth knowing about.
The inferred type describes the file as it exists on your machine right now. If the JSON is configuration that varies between environments, or a fixture that someone will edit, your types are quietly asserting facts about data you do not control. An optional field that happens to be present in the committed file will be typed as required.
Where that matters, do not import the JSON as a typed module. Read it, parse it, and validate it against a schema at the boundary - the resulting type is one you actually wrote down and can defend.
When not to import JSON at all
This is the part most guides skip. import is not always the right way to get JSON into your program, and the difference is not stylistic.
- An imported JSON module is frozen at load time. It is parsed once and cached for the life of the process. If the file changes on disk, your program will not notice. For anything you expect to be edited while running, use
fs.readFileand parse it yourself. - It is loaded eagerly and kept in memory. A large fixture imported at the top of a module is parsed on startup whether or not any request needs it. For big files, lazy-load them.
- The path is resolved like a module, not like a file. It is relative to the importing file, not to the process working directory - which is usually what you want, but it means you cannot point it at a user-supplied path.
- Secrets do not belong in it. An imported config file is bundled and shipped by most build tools. Environment variables exist for a reason.
The honest rule: import JSON when it is static data that ships with your code, and read it from the filesystem when it is state that lives independently of your code. Most bugs in this area come from treating the second case as the first.
Reading it the boring way
For completeness, the version that has no caveats at all and works on every Node version anyone still runs:
import { readFile } from 'node:fs/promises'
const raw = await readFile(new URL('./config.json', import.meta.url), 'utf8')
const config = JSON.parse(raw)Using new URL with import.meta.url keeps the path relative to the module rather than to the working directory, which is the ESM equivalent of the old dirname trick. It is three lines instead of one, and in exchange you get fresh data on every read and a parse error you can catch.
Picking one
Static import with with { type: 'json' } for package version strings, locale bundles, static lookup tables - anything that is genuinely part of the build. fs.readFile plus a schema check for configuration, user data, or anything that can change under you. require if you are in CommonJS and it already works.
The syntax was the annoying part and it is now settled. The interesting question was always the one underneath it: is this file code, or is it data? Answer that first and the right mechanism follows.