←All posts

Text Modules: The Web Is Finally Standardizing ?raw

Chrome's intent to ship text modules cleared review on September 9, targeting Chrome 155. Firefox already shipped it. After a decade of bundler-specific ?raw suffixes, importing a file as a string becomes a language feature.

Text Modules: The Web Is Finally Standardizing ?raw

Every non-trivial front end has a file it wants as a string. A WGSL shader. A SQL migration. A YAML config. An email template, a system prompt, a license blob, an SVG you need to mutate before injecting. And for the last decade, the way you got that string depended entirely on which bundler you happened to be using.

That is about to stop being a build-tool concern. On September 9, Chrome's intent to ship text modules collected its third LGTM on blink-dev, with estimated milestones of Chrome 155 on desktop, Android and WebView. Firefox has already shipped it. WebKit has it in development. The feature is a single new value for an existing import attribute:

import shader from "./cell.wgsl" with { type: "text" };
// shader is a string

What the proposal actually specifies

Import Text is a TC39 proposal at Stage 3, championed by Eemeli Aro and reviewed by Jordan Harband and Nicolò Ribaudo. It adds one thing: a "text" value for the type import attribute, which loads the target file and exposes its contents as a JavaScript string.

The design is deliberately minimal. The file is decoded as UTF-8, and there is no affordance for specifying another encoding. That is not an oversight — it is a boundary. If you need UTF-16 or Shift-JIS, the sibling Import Bytes proposal (Stage 2.7) gives you a Uint8Array and you decode it yourself:

import bytes from "./legacy.txt" with { type: "bytes" };
const text = new TextDecoder("utf-16le").decode(bytes);

This mirrors the shape of JSON modules, which have been in the language for years. The mechanism, the syntax, and the security model are all established; text modules just occupy the obvious empty slot next to them.

Because it touches loading, the proposal carries companion changes across three other specs: the HTML Standard, the Fetch Standard, and Content Security Policy. Chrome's intent notes that the Blink implementation required a new RequestDestination value for text — which is the detail worth flagging to anyone maintaining a strict CSP. A text module is a subresource fetch with its own destination, not a script fetch, so CSP integration is a real part of this feature rather than an afterthought.

Why fetch() was never the right answer

The obvious objection is that you can already do this:

const text = await fetch("./cell.wgsl").then(r => r.text());

The proposal lists three concrete limitations of that approach, and each one has cost real teams real time.

It is always async. Module initialization is where you want this data. Wrapping a module in top-level await to read a static file turns a build-time constant into a runtime dependency, and drags every importer of that module into the wait.

Relative paths resolve against the document, not the module. This is the subtle one. "./cell.wgsl" inside a module means "next to this module" to a human, and "next to the HTML page" to fetch(). Move the module, or publish it as a package, and the path silently breaks. Import specifiers resolve relative to the importing module, which is what you meant in the first place.

The fetch cannot start until the JavaScript executes. A static import is visible to the browser's preload scanner and to the module graph the moment the module is parsed. A fetch() call inside a function body is invisible until that function runs. On a cold load, that is the difference between a parallel request and a serial one.

Those three points are also a decent summary of why bundlers invented ?raw in the first place: the ergonomics of a static import are simply better, and the platform did not offer them.

What it replaces in your build config

Today, reading a file as a string looks different in every toolchain. Vite has import text from './file.txt?raw'. Webpack has the asset/source module type, and before that, raw-loader. Each of these is a non-standard specifier or a config rule, which means three downstream annoyances that will be familiar to anyone who has maintained a shared component library:

  1. The source is not portable. A file using ?raw will not run in Node, will not run in Deno, and will not run unbundled in a browser. It only runs through the bundler that understands the suffix.
  2. The types are ambient. You need a declare module "*?raw" shim, and your editor's understanding of the import depends on that shim being in the right tsconfig scope.
  3. The behavior is not specified. Whether the file is decoded as UTF-8, whether a BOM is stripped, what happens on a 404 — all of it is per-tool, and none of it is written down anywhere normative.

Server-side runtimes got there first and, tellingly, converged on the same syntax the proposal now specifies: both Deno and Bun already accept with { type: "text" }. That is the "don't standardize novelty, standardize what works" principle the proposal's README explicitly leans on, and it is why this went from Stage 1 to Stage 3 in about four months.

A realistic adoption path

Do not rewrite your ?raw imports this quarter. Interop is close but not here: Firefox has shipped, Chrome is landing in 155, WebKit is in development, and there is no Safari date. Baseline status is a 2027 conversation.

What is worth doing now:

Write new code in the standard syntax. For any module you author from today onward, use with { type: "text" }. Your bundler almost certainly already understands import attributes for JSON, and support for "text" is arriving across the Vite and Rollup ecosystem alongside the runtime implementations. If it does not yet, the fallback is a one-line alias — far cheaper than migrating in the other direction later.

Audit where you actually use raw text. Most codebases have somewhere between three and thirty of these imports, and a surprising number are load-bearing: shader sources, SQL, prompt templates, editor grammars. Knowing the list turns the eventual migration into an afternoon.

Check your CSP. If you run a strict policy and plan to use text modules, the new fetch destination is worth testing in Chrome 155 beta rather than discovering in production.

Do not ship it to users without a guard yet. Unlike a CSS property, a syntax feature is not gracefully ignored — an unsupported import attribute value is a load error, not a no-op. Until Safari lands, ship it through a build step that downlevels, exactly as you do for any other Stage 3 syntax.

Takeaways

  • Text modules add import x from "./file.txt" with { type: "text" } to JavaScript. Stage 3 at TC39, shipped in Firefox, targeting Chrome 155, in development in WebKit.
  • The value is not "new capability" — it is portability. One syntax that a browser, Deno, Bun and any bundler can all read, with no tool-specific suffix and no ambient type declaration. (Node support is still an open request.)
  • Static resolution is the underrated part: module-relative paths and preload-scanner visibility are things fetch() structurally cannot offer.
  • UTF-8 only, by design. Other encodings go through Import Bytes and TextDecoder.
  • Treat it as build-time syntax for now, not a runtime feature. It needs transpilation until Safari ships, and it needs a CSP review if your policy is strict.

The wider pattern here is the same one that produced import maps and JSON modules: capabilities that bundlers invented, proved in production for years, and then handed back to the platform in a form everyone can rely on. ?raw had a good run.

←Back to all posts
Next step

Need help shipping software?

Tell us what you're trying to build. A discovery call, a one-page summary within 48 hours, a proposal within a week.

Response · 48h·NDA on request·US contracts only