· 6 min read
JavaScript text modules: import files as strings with type text
JavaScript text modules let you import a .txt, .sql or shader file as a plain string with type: "text". Here is how they work and where they run.

JavaScript text modules let you load any file as a plain string with a normal import statement: import sql from "./query.sql" with { type: "text" }. The browser or runtime fetches the file, decodes it as UTF-8 and hands you the result as the module's default export. It shipped in Chrome and Edge 155 in October 2026 and in Firefox 153, and Deno, Bun and Node.js (behind a flag) support it too.
If you have ever written await fetch(url).then(r => r.text()) at the top of a module, or reached for a bundler plugin just to inline a template, this is the standard replacement.
What are JavaScript text modules?
An import attribute is the with { ... } clause after an import specifier. It tells the host how to interpret the file instead of guessing from the extension. You may already use type: "json" for JSON modules and type: "css" for CSS module scripts. Text modules add a third value, "text".
import banner from "./banner.txt" with { type: "text" };
import fragmentShader from "./blur.frag" with { type: "text" };
console.log(typeof banner); // "string"
The module has exactly one export, default, and its value is the file contents. There are no named exports and no parsing. A text module never runs code; it is data.
The feature started as a TC39 proposal (TC39 is the committee that standardizes JavaScript) and is now at Stage 3, which means the design is settled and engines are expected to implement it. The browser loading rules are being added to the HTML standard.
How do text modules behave in the browser?
A few rules are worth knowing before you rely on them.
The attribute is required. Without with { type: "text" }, the browser treats the file as JavaScript. A .txt file served as text/plain then fails the strict MIME check for module scripts and the import throws.
There is no MIME type check. JSON modules insist on a JSON MIME type. Text modules don't care what Content-Type the server sends, because any bytes can be read as text. That makes them easy to use with servers that label .sql or .glsl files as application/octet-stream.
Decoding is always UTF-8. There is no option to pick another encoding, by design. If your file is in Latin-1 or UTF-16, convert it first or you will get replacement characters.
It is a normal module request. The file goes through the module map, which caches each module by URL and type. Import the same file from ten places and it is fetched once, and every importer gets the same string. Cross-origin imports need CORS headers, just like any other module.
Dynamic import works the same way, with the attribute passed in the options object:
const { default: help } = await import(`./help/${lang}.md`, {
with: { type: "text" },
});
Why use a text module instead of fetch()?
You could always load text with fetch(). The difference is where the dependency lives.
fetch() + .text() | Text module | |
|---|---|---|
| Declared in | Runtime code | The import graph |
| Loaded before your code runs | No | Yes, for static imports |
| Visible to bundlers and preload tools | Hard | Yes |
| Cached per URL by the module map | No, uses HTTP cache only | Yes |
| Works outside the browser | Depends on the runtime | Same syntax in Deno, Bun, Node |
With a static import, the string exists before the first line of your module executes. You don't need top-level await, a loading state or error handling scattered through your code. Build tools can see the dependency in the import graph and inline or hash it, the same way they handle JavaScript files.
Good fits are files that are logically part of your code but awkward to write as JavaScript strings:
- WebGL and WebGPU shader source.
- SQL queries kept in
.sqlfiles so your editor highlights them. - LLM prompt templates and system prompts.
- Markdown or HTML snippets rendered at runtime.
- Test fixtures and golden files.
For large or user-specific data, keep using fetch(). A static import blocks the module graph until the file arrives, so a multi-megabyte text file in your entry point delays everything after it.
Where can you use text modules today?
Support is recent and uneven, so check each environment you target.
| Environment | Status |
|---|---|
| Chrome and Edge | Enabled by default from version 155 |
| Firefox | Enabled by default from version 153 |
| Safari | In development in WebKit |
| Deno | Behind --unstable-raw-imports |
| Bun | Supported |
| Node.js | Behind --experimental-import-text since 26.5.0 |
In Node.js, the flag is required on current releases. Without it, importing a .txt file fails with an unknown file extension error.
node --experimental-import-text app.mjs
In TypeScript, the compiler doesn't know what a .sql or .frag file exports. Declare it once in a .d.ts file:
declare module "*.sql" {
const text: string;
export default text;
}
If you already use a bundler convention such as Vite's ?raw suffix, it keeps working. The difference is that the with { type: "text" } form is standard syntax, so the same import can eventually run unbundled in a browser or a server runtime. Check whether your bundler understands the attribute before you switch, since support there is still arriving.
What are the gotchas?
Safari users need a fallback. Until WebKit ships it, an unbundled page that uses a static text import fails to load the whole module graph in Safari. For public sites, let your bundler inline the import, or wrap a dynamic import in try/catch and fall back to fetch().
Don't use it in published npm packages yet. A library that imports text this way forces every consumer's runtime and bundler to support it. Node.js still has it behind a flag, so keep it to applications, scripts and internal tooling for now.
Changing the file needs a reload. A text module is loaded once per realm and then cached by the module map. On a long-running server, editing the .sql file on disk does nothing until the process restarts. If you need hot reloading, read the file with fs instead.
Binary files need something else. Images, fonts and WebAssembly are not text, and UTF-8 decoding will corrupt them. Use fetch() with arrayBuffer() for those.
Key takeaways
import x from "./file" with { type: "text" }gives you the file as a string in the default export.- The attribute is required, there is no MIME type check, and decoding is always UTF-8.
- It ships by default in Chrome and Edge 155 and Firefox 153; Safari is still in development.
- Bun supports it, while Deno and Node.js need a flag.
- Use it for shaders, SQL, prompts and templates; keep
fetch()for large, dynamic or binary data.
FAQ
Is a text module the same as a JSON module?
No. A JSON module parses the file and gives you an object, and it requires a JSON MIME type. A text module gives you the raw string and accepts any MIME type.
Can I import a file as bytes instead of text?
Not with type: "text", which always decodes as UTF-8. For binary data, use fetch() and arrayBuffer().
Do text modules work in web workers?
Yes, in browsers that support them. Module workers use the same module loader, so the same import syntax applies there.