
# Custom JavaScript plugins

Installing `oxc-tsrx` puts an `oxlint` on your PATH that already lints `.tsrx`.
Write one ordinary Oxlint JavaScript plugin, list it in `.oxlintrc.json`, and it
runs on `.js`, `.ts`, `.jsx`, and `.tsx` directly and on `.tsrx` through a TSX
copy, reporting at the line and column you wrote. Two things to know first:

- The `.tsrx` half costs one extra parse per file, and `oxlint` says so on
  stderr. See [turning the extra parse off](#turning-the-extra-parse-off).
- Your rule sees TSRX control syntax as the JavaScript it compiles to, not as
  `@if` and `@for` nodes. See [what your rule sees on
  `.tsrx`](#what-your-rule-sees-on-tsrx).

On a Vite+ app, read [in a Vite+ project](#in-a-vite-project) as well: the
`oxlint` on your PATH there is Vite+'s, not this package's.

## Set up the project

You need Node.js 20.19 or newer, and one install
([Getting Started](/guide/getting-started#install) has the per-host details):

```sh
npm install oxc-tsrx@0.2.3
```

Save this as `src/TaskList.tsrx`:

```tsrx
type Task = { id: string; label: string; done: boolean };

export function TaskList({ tasks, ready }: { tasks: Task[]; ready: boolean }) @{
  debugger;

  @if (ready) {
    <ul class="tasks">
      @for (const task of tasks) {
        <li>{task.label}</li>;
      }
    </ul>;
  } @else {
    <p>Loading tasks</p>;
  }
}
```

Real output, captured at build time. The whole sample project is one src/TaskList.tsrx and an install of oxc-tsrx. There is no configuration file yet.

```text
# The oxlint that oxc-tsrx installs already reads .tsrx
$ npx oxlint src/TaskList.tsrx
src/TaskList.tsrx:4:3: warning eslint(no-debugger): `debugger` statement is not allowed
Found 1 warning and 0 errors.
Finished in 7ms on 1 file with 95 rules using 1 thread.
```

The missing `key` and the `debugger` are both there on purpose. With no config
file and no build step, `oxlint` reported a built-in OXC rule at the line you
wrote. Those rules are Rust, so you cannot add one. What you can add is a plugin.

## What are the nodes called?

A lint rule is a set of callbacks named after node types. Every TSRX control
block has its own node type, shaped like the JSX nodes you already know:

| You write | The node you visit |
| --- | --- |
| `@if` / `@else` | `JSXIfExpression` |
| `@for` / `@empty` | `JSXForExpression` |
| `@switch` / `@case` / `@default` | `JSXSwitchExpression` |
| `@try` / `@pending` / `@catch` | `JSXTryExpression` |
| `@{ }` statement containers | `JSXCodeBlock` |

Everything else uses the same shapes as `oxc-parser`; the [Parsing
guide](/guide/parsing) covers the tree, and
[`explore-tsrx-ast.mjs`](https://github.com/markless-dev/oxc-tsrx/blob/main/examples/custom-js-plugins/explore-tsrx-ast.mjs)
prints the node types in a file of your own.

## Write an oxlint JavaScript plugin

An Oxlint plugin is an ES module exporting `{ meta, rules }`, where each rule's
`create(context)` returns a visitor keyed by node type. Pass `node` to
`context.report` to put the diagnostic on the code you wrote, and `meta.name` is
the prefix the rules are configured under.

Copy
[`oxlint-demo-plugin.mjs`](https://github.com/markless-dev/oxc-tsrx/blob/main/examples/custom-js-plugins/oxlint-demo-plugin.mjs)
into your project. It has one rule, `require-keyed-map`, which visits
`CallExpression` and reports JSX returned from `.map()` without a `key`.

Oxlint only loads a plugin you list, and only enables a rule you turn on. Save
this as `.oxlintrc.json`:

```json
{
  "jsPlugins": ["./oxlint-demo-plugin.mjs"],
  "rules": {
    "tsrx-demo/require-keyed-map": "error"
  }
}
```

The rule needs an ordinary file to run on, so add
[`src/TaskRow.tsx`](https://github.com/markless-dev/oxc-tsrx/blob/main/examples/custom-js-plugins/src/TaskRow.tsx),
a React component whose `.map()` call has the missing-key problem.

Real output, captured at build time. The sample project now has the .oxlintrc.json and oxlint-demo-plugin.mjs from above, plus the ordinary src/TaskRow.tsx.

```text
# Your own JavaScript rule, running inside the oxlint you installed
$ npx oxlint src/TaskRow.tsx
src/TaskRow.tsx:11:9: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop.
```

## The same plugin on `.tsrx`

Leave everything as it is and point the same command at the `.tsrx` file:

Real output, captured at build time, from the same project and the same .oxlintrc.json, pointed at the .tsrx file instead.

```text
# Same plugin, same config, one .tsrx file
$ npx oxlint src/TaskList.tsrx
src/TaskList.tsrx:4:3: warning eslint(no-debugger): `debugger` statement is not allowed
Found 1 warning and 0 errors.
Finished in 63ms on 1 file with 95 rules using 1 thread.
oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
```

That `oxlint (oxc-tsrx):` line is the disclosure: the `.tsrx` half costs one
more parse, and the command says so every time, naming the setting that turns it
off. Your rule found nothing here, because `require-keyed-map` wants a `.map()`
call and this file has an `@for` block. Give it something to find, as
`src/TaskFeed.tsrx`:

```tsrx
type Task = { id: string; label: string; done: boolean };

export function TaskFeed({ tasks }: { tasks: Task[] }) @{
  const rows = tasks.map((task) => <li>{task.label}</li>);

  <ul class="feed">{rows}</ul>;
}
```

Real output, captured at build time, after src/TaskFeed.tsrx was added to the same project.

```text
# Your own rule, reporting on the .tsrx file you wrote
$ npx oxlint src/TaskFeed.tsrx
src/TaskFeed.tsrx:4:36: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop.
Found 0 warnings and 1 error.
Finished in 63ms on 1 file with 95 rules using 1 thread.
oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
```

Your own rule, at line 4, column 36: the column of the `<li>` you wrote. One
command over a directory does both halves, and the editor needs nothing extra,
since the language server runs the same plugin on the buffer. [Editor
integration](/integrations/editor#your-own-javascript-rules-in-the-editor) names
its one activation step.

## In a Vite+ project

Two things differ, and both are Vite+'s doing rather than this package's.

**`node_modules/.bin/oxlint` belongs to Vite+.** Running it exits 1 and tells
you to run `vp lint`. Do that, or use this package's own
`node_modules/oxc-tsrx/bin/oxlint`. The editor needs nothing further: `setup`
points the extension at this package.

**`vp lint` does not read `.oxlintrc.json`.** Vite+ keeps lint configuration in
the `lint` block of `vite.config.ts`, so a rule you want on both surfaces is
declared twice. Add `{ name: "house-rules", specifier: "./house-rules.mjs" }` to
`lint.jsPlugins` and your rule to `lint.rules`, deleting nothing the scaffold
wrote. [The finished
file](https://github.com/markless-dev/oxc-tsrx/blob/main/examples/custom-js-plugins/vite-plus/vite.config.ts)
is in the examples directory.

Making `.tsrx` a *language* in the editor is a separate job owned by the TSRX
toolchain. [Vite and
Vite+](/guide/getting-started#if-something-goes-wrong) has that
list and the type-aware dependency the scaffold needs.

## What your rule sees on `.tsrx`

A `.tsrx` file is linted by a Rust process with no Node.js runtime, so `oxlint`
runs the published Oxlint binary over a legal TSX copy of your file, with your
own `.oxlintrc.json`. Severities, rule options, `extends`, and `overrides`
resolve as they do elsewhere, and an installed Oxlint outside `>=1.74.0 <2.0.0`
is refused rather than used.

Your rule sees that copy. Four things follow from it:

- **Control blocks.** TSRX control syntax is already compiled away, so a rule keyed on `JSXForExpression` never fires. Your rule does visit the compiled statement, but a report on one is dropped, because its span covers text the copy wrote.
- **context.filename.** Your rule is handed the copy’s path. The path relative to your working directory survives, so a rule testing for `src/` works, but one comparing an absolute path or expecting a `.tsrx` extension does not. The diagnostic still lands on your file.
- **Dropped reports.** The copy adds markers and wrappers matching nothing you typed, and a report on one of those has no position in your file to point at. It is dropped, and the count is never silent.
- **overrides globs.** The copy is named `View.tsrx.tsx`, which `**/*.tsrx` does not match, so `oxlint` emits each of your `overrides[].files` and `excludeFiles` globs with `.tsx` appended as well.

For a rule that must report on TSRX control flow itself, see
[the ESLint route](#when-your-rule-must-see-if-and-for-as-tsrx-nodes).

## Turning the extra parse off

To stop paying the second parse, add `"settings": { "oxcTsrx": {
"jsPluginsOnTsrx": false } }` to `.oxlintrc.json`. Your plugins keep running on
ordinary files, and on `.tsrx` the command now refuses out loud rather than
dropping your rule and reporting success:

Real output, captured at build time, from the same project after settings.oxcTsrx.jsPluginsOnTsrx was set to false.

```text
# With the lane switched off, the .tsrx half refuses out loud
$ npx oxlint src/TaskFeed.tsrx
oxlint (oxc-tsrx): JavaScript plugins are not hosted by the native TSRX lint target itself: it is a Rust process with no Node runtime. The `oxlint` command OXC for TSRX installs runs them on .tsrx for you, by linting the TSX projection with the published Oxlint binary and mapping every diagnostic back to your authored source. Run `oxlint` instead of this target, or remove the settings.oxcTsrx.jsPluginsOnTsrx false opt-out that turned that lane off
```

The same setting turns the editor's half off, where the refusal arrives as one
`lint-unavailable` diagnostic carrying the same text. The [configuration
guide](/integrations/configuration#jsplugins-and-the-two-lanes) has the full
support matrix for the native `.tsrx` path.

## When your rule must see `@if` and `@for` as TSRX nodes

There is no released route for that today. Your rule always sees the TSX copy,
where the control blocks have already become ordinary statements.

The repository carries a local ESLint adapter that does hand a rule the authored
tree, in
[`examples/custom-js-plugins`](https://github.com/markless-dev/oxc-tsrx/tree/main/examples/custom-js-plugins).
It is AST-only, since the parser API exposes no token stream. Running the same
rule inside Oxlint waits on OXC PR
[#24262](https://github.com/oxc-project/oxc/pull/24262), a draft as of
2026-07-26.

The runnable version of this page is in
[`examples/custom-js-plugins`](https://github.com/markless-dev/oxc-tsrx/tree/main/examples/custom-js-plugins).
Oxlint is pinned and tested at 1.74.0. Last audited: 2026-07-27.
