# Why does my agent miss dynamically imported components?

> Rule AR-37: In React and Next.js, don't call import() with a computed path for components agents edit.

`import("./widgets/" + name)` names no file. A search for `Chart.tsx`'s importers finds nothing, and a rename of the file breaks the page at runtime. Write one `import("./widgets/Chart")` per component, in a table keyed by a literal union, and every file has a visible importer.

## Why it happens
MDN's page on `import()` states that dynamic imports "allow any expression as the module specifier, not necessarily string literals." Its use case is when the specifier "needs to be constructed dynamically", which static `import` cannot do. The same page advises: "Use dynamic import only when necessary. The static form is preferable for loading initial dependencies, and can benefit more readily from static analysis tools and tree shaking."

The static declaration is the opposite design. MDN says `import` declarations "are designed to be syntactically rigid (for example, only string literal specifiers, only permitted at the top-level ...), which allows modules to be statically analyzed and linked before getting evaluated." Resolving a specifier is left to the host: the ECMAScript specification "does not define how module specifiers are resolved", and bundlers define their own rules on top.

Next.js documents its rule for `next/dynamic`: "In `import('path/to/component')`, the path must be explicitly written. It can't be a template string nor a variable." The import also has to be inside the `dynamic()` call "for Next.js to be able to match webpack bundles / module ids to the specific `dynamic()` call and preload them before rendering," and `dynamic()` cannot be used inside React rendering since it must be at the top level of the module, similar to `React.lazy`. The docs describe `next/dynamic` as a composite of `React.lazy()` and Suspense.

So there are two separate costs, and both come from a path that isn't a literal:
- **For the framework:** the bundler can't tell which files a computed specifier may load.
- **For an agent:** a literal path is a string a search can match. A computed one is built from parts, so the file name never appears in the importer.

Next.js also documents magic comments such as `webpackIgnore: true` and `turbopackIgnore: true`, which leave the import expression as-is in the output and are described as "useful for runtime-only modules." That is a deliberate opt-out of bundling, and it is the same visibility trade as a computed path.

## Bad and good
A dashboard page shows widgets chosen by name.

```ts tab
// bad: the path is built at runtime
import dynamic from "next/dynamic";

export function Widget({ name }: { name: string }) {
  const Component = dynamic(() => import(`./widgets/${name}`));
  return <Component />;
}
```

This breaks two Next.js rules at once: the path is a template string, and `dynamic()` runs during rendering. It also hides the three files in `widgets/`. Renaming `Chart.tsx` to `LineChart.tsx` changes nothing the compiler can see.

```ts tab
// good: one literal import per widget, in a table keyed by a literal union
import dynamic from "next/dynamic";
import type { ComponentType } from "react";

type WidgetName = "chart" | "table" | "summary";

const widgets = {
  chart: dynamic(() => import("./widgets/Chart")),
  table: dynamic(() => import("./widgets/Table")),
  summary: dynamic(() => import("./widgets/Summary")),
} satisfies Record<WidgetName, ComponentType>;

export function Widget({ name }: { name: WidgetName }) {
  const Component = widgets[name];
  return <Component />;
}
```

Each `dynamic()` call is at module top level with a literal path, as the Next.js docs require. Every widget file now has an importer, `WidgetName` is a union a caller must satisfy, and `satisfies Record<WidgetName, ...>` makes the compiler report a missing entry (see AR-07 for the `satisfies` behaviour).

## Trade-offs and when a computed path is acceptable
- **The table is more lines.** Each widget costs one entry. That entry is the visible importer.
- **Plugins and user-supplied modules** can't be listed ahead of time. Keep those loads in one small module and name the contract (AR-09), and don't let that module spread into the rest of the app.
- **Loading a library on demand** (`await import("fuse.js")` in an event handler, as the Next.js guide shows) uses a literal and is fine.
- **Server Components:** the Next.js App Router guide says `ssr: false` is not supported in Server Components, and that when a Server Component dynamically imports a Client Component, automatic code splitting is currently not supported. Put the table in a Client Component when you need `ssr: false`.
- **Runtime-only imports** with `webpackIgnore` or `turbopackIgnore` are a deliberate choice. Mark them with a comment saying where the targets live.

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| eslint | `no-restricted-syntax` with a selector for the ESTree node `ImportExpression` and an attribute filter such as `[source.type!='Literal']` | `import()` whose argument is not a plain string literal. The rule takes ESTree selectors (documented), and the attribute filter syntax is shown on the rule page. Checked with eslint 9.39.5: this selector flags `import()` calls with a non-literal argument and leaves a plain string-literal `import()` clean. | 2026-10-30 |
| typescript-eslint | `no-explicit-any` (in `recommended`) | `any` used to type the result of a computed import | 2026-10-30 |
| tsconfig | `strict` | an implicit `any` on a module loaded by variable | 2026-10-30 |

In `eslint.config.js`:
```js
{
  rules: {
    "no-restricted-syntax": [
      "error",
      {
        selector: "ImportExpression[source.type!='Literal']",
        message: "Write the module path as a string literal.",
      },
    ],
  },
}
```
Checked with eslint 9.39.5: a template-string `import()` is flagged. A template literal with no expressions has the node type `TemplateLiteral`, so it is also flagged by this selector. Whether you want that is a project choice.

## Rule block for AGENTS.md / CLAUDE.md
```markdown
- Write `import()` and `dynamic(() => import("..."))` with a string literal path. Never build the path from a variable or template string.
- To load one of several components by name, add a table: `{ chart: dynamic(() => import("./widgets/Chart")), ... }`
  keyed by a literal union type, and look the component up in it.
- Call `dynamic()` at module top level, never inside a component.
- When you add or rename a file in a directory that has such a table, edit the table in the same change.
```

## Edge cases
- **A file only reachable by a computed path** has no importer. Dead-code checks and "who imports this" searches report it as unused, and a cleanup pass may delete it. That is why CH-06 and CH-11 are worth reading before tidying.
- **`React.lazy`** takes the same form, a function that returns `import("...")`. Next.js notes that `dynamic()` must be marked at the top level of the module "similar to `React.lazy`." Use a literal there too.
- **Named exports:** `import("../components/hello").then((mod) => mod.Hello)` is the documented form and keeps the path literal.
- **Pages Router and App Router** share `next/dynamic` and "behave the same way," per the docs, so the rule is the same in both.
- **CommonJS:** the same pattern with `require(variable)` is covered in AR-38.

## References
- MDN, `import()`: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import
- MDN, `import` declaration: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import
- Next.js, lazy loading (App Router): https://nextjs.org/docs/app/guides/lazy-loading
- Next.js, lazy loading (Pages Router): https://nextjs.org/docs/pages/guides/lazy-loading
- ESLint `no-restricted-syntax`: https://eslint.org/docs/latest/rules/no-restricted-syntax
- typescript-eslint `no-explicit-any`: https://typescript-eslint.io/rules/no-explicit-any/
- TSConfig reference: https://www.typescriptlang.org/tsconfig/

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: tool checks resolved (eslint 9.39.5, tsc 7.0.2, pyright 1.1.414).
- 2026-10-30: remaining checks closed (claims reduced to documented behaviour)

## Related

- [AR-05: Why do fallback imports make agents edit the wrong code?](https://beta.llmgraph.in/agent-readable/ar-05.md)
- [AR-07: Why does my coding agent miss calls made through getattr?](https://beta.llmgraph.in/agent-readable/ar-07.md)
- [AR-38: How should Express routes be organised for coding agents?](https://beta.llmgraph.in/agent-readable/ar-38.md)

Last verified 2026-10-30
