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:

On a Vite+ app, read 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 has the per-host details):

npm install oxc-tsrx@0.2.3

Save this as src/TaskList.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>;
  }
}
See it run
# 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.
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.

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 covers the tree, and explore-tsrx-ast.mjs (opens in new tab) 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 (opens in new tab) 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:

{
  "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 (opens in new tab), a React component whose .map() call has the missing-key problem.

See it run
# 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.
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.

The same plugin on .tsrx#

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

See it run
# 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 } }.
Real output, captured at build time, from the same project and the same .oxlintrc.json, pointed at the .tsrx file instead.

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:

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>;
}
See it run
# 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 } }.
Real output, captured at build time, after src/TaskFeed.tsrx was added to the same project.

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 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 (opens in new tab) is in the examples directory.

Making .tsrx a language in the editor is a separate job owned by the TSRX toolchain. Vite and Vite+ 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:

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.

You wrote
@for (const task of tasks) {
  <li>{task.label}</li>;
}
Your rule sees
for (const task of tasks) {
  <li>{task.label}</li>;
}

Measured one rule per node type: JSXElement reported 7 and dropped 0, while IfStatement, SwitchStatement, and FunctionDeclaration each reported 0 and dropped 1.

For a rule that must report on TSRX control flow itself, see the ESLint route.

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:

See it run
# 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
Real output, captured at build time, from the same project after settings.oxcTsrx.jsPluginsOnTsrx was set to false.

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 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 (opens in new tab). It is AST-only, since the parser API exposes no token stream. Running the same rule inside Oxlint waits on OXC PR #24262 (opens in new tab), a draft as of 2026-07-26.

The runnable version of this page is in examples/custom-js-plugins (opens in new tab). Oxlint is pinned and tested at 1.74.0. Last audited: 2026-07-27.