Skip to content
Dev Tools Intermediate Tutorial

Write a Custom ESLint Plugin to Enforce Your Team's Coding Standards

Build, test, and ship an ESLint rule that blocks your team's anti-patterns at commit time.

Lenn Voss
Lenn Voss
Cloud & Infrastructure Writer · Aug 10, 2026 · 5 min read
Write a Custom ESLint Plugin to Enforce Your Team's Coding Standards

What you'll build

A custom ESLint plugin with one team-specific rule — no-raw-fetch, which forces every network call through your apiClient wrapper instead of bare fetch() — plus tests, and a pre-commit hook that blocks violations before they ever reach review.

Prerequisites

Verified against ESLint 10.8.1, husky 9.1.7, and lint-staged 17.3.0 in August 2026.

  • Node.js ^20.19.0 || ^22.13.0 || >=24 (ESLint 10's supported range) and npm 10+
  • git, and a shell (macOS/Linux; on Windows use Git Bash or WSL)
  • Heads up if you're coming from older guides: ESLint 10 removed the .eslintrc.* system entirely. Everything below uses flat config (eslint.config.js).

1. Scaffold the plugin package

A plugin is just an npm package whose default export maps rule names to rule objects.

mkdir eslint-plugin-team && cd eslint-plugin-team
npm init -y
mkdir rules tests
npm install --save-dev eslint

Replace the generated package.json with:

{
  "name": "eslint-plugin-team",
  "version": "1.0.0",
  "description": "Team coding standards as ESLint rules",
  "type": "module",
  "main": "index.js",
  "scripts": {
    "test": "node tests/no-raw-fetch.test.js"
  },
  "keywords": ["eslint", "eslintplugin", "eslint-plugin"],
  "peerDependencies": {
    "eslint": ">=10.0.0"
  },
  "devDependencies": {
    "eslint": "^10.8.1"
  }
}

ESLint stays a peerDependency so consumers use their own copy; the eslint-plugin- name prefix and keywords are the npm conventions that make plugins discoverable.

2. Write the rule

Rules are AST visitors: ESLint parses each file and calls your handler for every node type you register. Create rules/no-raw-fetch.js:

export default {
  meta: {
    type: "problem",
    docs: {
      description: "Require apiClient instead of direct fetch() calls",
    },
    schema: [],
    messages: {
      useApiClient:
        "Call apiClient instead of fetch() directly so auth headers and retries are applied.",
    },
  },
  create(context) {
    return {
      CallExpression(node) {
        if (node.callee.type !== "Identifier" || node.callee.name !== "fetch") {
          return;
        }
        const scope = context.sourceCode.getScope(node);
        const ref = scope.references.find((r) => r.identifier === node.callee);
        // A `fetch` defined in the file (import, param, const) is a wrapper — allow it.
        if (ref?.resolved && ref.resolved.defs.length > 0) {
          return;
        }
        context.report({ node, messageId: "useApiClient" });
      },
    };
  },
};

The scope check is what separates a real rule from a grep: it resolves the fetch identifier and only reports when it points at the global, so someone's imported fetch wrapper or a fetch parameter doesn't get flagged. messages + messageId keeps wording in one place and lets tests assert the ID instead of a string.

3. Export the plugin

Create index.js:

import noRawFetch from "./rules/no-raw-fetch.js";

export default {
  meta: {
    name: "eslint-plugin-team",
    version: "1.0.0",
    namespace: "team",
  },
  rules: {
    "no-raw-fetch": noRawFetch,
  },
};

4. Test it with RuleTester

ESLint ships RuleTester, which runs your rule against code snippets and throws on any mismatch — no test framework required. Create tests/no-raw-fetch.test.js:

import { RuleTester } from "eslint";
import rule from "../rules/no-raw-fetch.js";

const ruleTester = new RuleTester();

ruleTester.run("no-raw-fetch", rule, {
  valid: [
    "apiClient.get('/users');",
    "import { fetch } from './api-client.js'; fetch('/users');",
    "function load(fetch) { return fetch('/users'); }",
  ],
  invalid: [
    {
      code: "fetch('/api/users');",
      errors: [{ messageId: "useApiClient" }],
    },
    {
      code: "async function load(url) { return await fetch(url); }",
      errors: [{ messageId: "useApiClient" }],
    },
  ],
});

console.log("All no-raw-fetch tests passed.");

Run npm test. RuleTester checks both directions — valid code must produce zero reports, invalid code exactly the reports you listed.

5. Wire it into your project

In the project that should enforce the rule (a sibling directory here — after publishing, install by name instead):

cd ../your-app
npm install --save-dev eslint ../eslint-plugin-team

Create eslint.config.js (add "type": "module" to the app's package.json, or name it eslint.config.mjs):

import { defineConfig } from "eslint/config";
import team from "eslint-plugin-team";

export default defineConfig([
  {
    files: ["**/*.js"],
    plugins: { team },
    rules: {
      "team/no-raw-fetch": "error",
    },
  },
]);

The key in plugins: { team } sets the rule prefix — that's why the rule is referenced as team/no-raw-fetch.

6. Enforce it on every commit

husky installs git hooks; lint-staged runs ESLint on just the staged files, so hooks stay fast on big repos.

npm install --save-dev husky lint-staged
npx husky init
echo "npx lint-staged" > .husky/pre-commit

husky init adds a "prepare": "husky" script (so hooks reinstall on npm install) and creates .husky/pre-commit with a default npm test command, which the echo replaces. Then add to the app's package.json:

"lint-staged": {
  "*.js": "eslint --max-warnings=0"
}

lint-staged appends the staged file paths to that command, and a nonzero ESLint exit aborts the commit.

Verify it works

In the plugin, npm test should print:

All no-raw-fetch tests passed.

In the app, add a violation and lint:

// app.js
export async function loadUsers() {
  const res = await fetch("/api/users");
  return res.json();
}
npx eslint .

Expected output (exit code 1):

/path/to/your-app/app.js
  2:21  error  Call apiClient instead of fetch() directly so auth headers and retries are applied  team/no-raw-fetch

✖ 1 problem (1 error, 0 warnings)

Now git add app.js && git commit -m "test" — the hook runs lint-staged, prints the same error, and ends with:

husky - pre-commit script failed (code 1)

Switch the call to apiClient.get("/api/users") and the commit goes through. Done: the anti-pattern is now physically hard to ship.

Troubleshooting

  • TypeError: Key "rules": Key "team/no-raw-fetch": Could not find "no-raw-fetch" in plugin "team". — The rule name in rules doesn't match a key in the plugin's rules object, or the prefix doesn't match your plugins: { ... } key. The prefix comes from that key, not from the npm package name.
  • TypeError: context.getSourceCode is not a function — You copied a pre-2024 tutorial. ESLint 10 removed the deprecated context methods; use context.sourceCode and context.filename properties instead.
  • AssertionError: Should have 1 error but had 0: [] — RuleTester ran your invalid case but the rule never reported. The usual cause is a typo in the visitor key (CallExpresion fails silently — unknown node types are simply never visited) or a guard clause returning too early.
  • .git can't be found from npx husky init — You're not in a git repository, or you're in a subdirectory of one. Run git init (or move to the repo root) first.

Next steps

Cover more of the AST: paste code into AST Explorer to see the node types your next rule needs. From there, add an auto-fix by declaring meta.fixable: "code" and returning fixer.replaceText(...) from context.report, add rule options via meta.schema, and bundle a shareable configs.recommended so consumers get every team rule with one extends. The custom rule tutorial and plugin docs cover all three. When you're ready to share it, npm publish the plugin (scope it as @your-org/eslint-plugin-team and publish with --access public if your registry requires it), then swap the local path install for the published version across your repos.

Sources & further reading

  1. Create Plugins — eslint.org
  2. Custom Rules — eslint.org
  3. Node.js API Reference (RuleTester) — eslint.org
  4. ESLint v10.0.0 released — eslint.org
  5. husky — Get started — typicode.github.io
  6. lint-staged — github.com
Lenn Voss
Written by
Lenn Voss · Cloud & Infrastructure Writer

Lenn writes about cloud platforms, Kubernetes internals, and the infrastructure decisions that quietly make or break engineering organizations. Based in Berlin's vibrant tech scene, they have a talent for turning dense platform-engineering topics into prose that people actually finish reading.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading