Home / Blog / Writing Custom Vue ESLint Rules and Why?
Writing Custom Vue ESLint Rules and Why?

Writing Custom Vue ESLint Rules and Why?

Daniel Kelly
Daniel Kelly
October 20th 2025

Linting is one of those developer‐tools that often gets taken for granted — you install ESLint, pick a config (probably the one from eslint‑plugin‑vue and/or some other popular ones), and you’re off to the races. But what happens when you’re working on a medium/large-Vue project (or team) with domain-specific patterns, or anti‐patterns that you keep seeing, and you want to encode your team’s style, or prevent mistakes that are specific to your codebase? That’s where writing custom ESLint rules can shine.

I saw this tweet by Harlan Wilton not too long ago about exactly this subject.
I thought it was a great idea and wanted to write about it. So let's dive in and see what it's all about.

In this article we'll go over:

  1. Why you might consider custom Vue ESLint rules (the motivations)
  2. What it takes from the vantage point of Vue / Single File Components (SFCs)
  3. Some real-world examples from Harlan Wilton’s tweet and his plugin
  4. Some more rules from the eslint-plugin-harlanzw plugin
  5. How you might build your own rule (walk‐through high‐level)

Why write custom Vue ESLint rules?

Preventing team‐specific anti‐patterns

This is exactly the kicker: you see recurring mistakes, code smells, or architectural drifts (e.g., mis-use of watchers, mixing concerns in templates, inconsistent state usage) in Vue components that the generic lint rules don’t catch. By writing custom rules, you can codify your guidelines or guardrails. This not only helps enforce for your human team members but AI agents too!

Enforcing consistency and increasing code quality

Generic rules (indentation, quotes, etc.) are useful — but they don’t enforce larger domain / framework patterns. For Vue SFCs you might want to enforce things like: "Don’t use v-html on untrusted content", or "Components must have a name property", or "Don’t access $root from children" etc. A custom rule can surface those.

Improved onboarding & maintenance

When new devs join the team, a set of custom lint checks helps them adhere to your architecture/style right away. It also helps during maintenance: when you refactor or upgrade, the rules act like “unit tests” for style or structure.

Incremental adoption & automation

Lint rules integrate into your editor, CI pipelines, and code review workflows. If you build a custom rule and set it to “warn” and gradually elevate to “error”, you can smoothly adopt them. The feedback loop becomes almost immediate.

Vue SFC specifics

Vue adds complexity: templates, <script setup>, <style scoped>, composition API, reactive refs vs. normal values — generic JavaScript linting doesn’t cover those. Tools like eslint-plugin-vue help; but when your use of Vue is opinionated, you might want more fine-grained checks. For example you might require use of the composition API only, or require all emits to be defined with defineEmits.

Some Custom ESLint Rule Examples from Harlan Wilton

Harlan Wilton is an avid contributor to the Nuxt and Vue communities. You're probably familiar with some of his Nuxt modules including Nuxt SEO and Nuxt OG Image.

I've used the modules myself but what caught my eye recently was his tweet about the new eslint-plugin-harlanzw plugin.

Harlan’s tweet said:

The three he included in the tweet were:

Vue No Faux Composables

This rules prevents naming a function with the use prefix if it's not using the composition API to keep up with reactive state.

vue-no-faux-composables

Vue No Ref Access in Templates

This rules prevents accessing a ref in the template since it's already "unwrapped".

vue-no-ref-access-in-templates

Vue No toRefs on Props

This rule prevents using toRefs since they're already reactive and can be reactively destructured from defineProps as of Vue 3.5.

vue-no-torefs-on-props

These are just a few of the available rules.

Some of the most exciting rules from eslint-plugin-harlanzw

Peaking into the plugin itself, there are a lot of other interesting rules that are worth checking out.

  1. vue-no-passing-refs-as-props - Avoid passing refs as props. Pass the unwrapped value using ref.value or use reactive() instead.

  2. nuxt-prefer-nuxt-link-over-router-link - This rule recommends using instead of in Nuxt applications. NuxtLink provides better performance optimizations, prefetching capabilities, and seamless integration with Nuxt's router system.
  3. nuxt-no-side-effects-in-setup - Enforce that side effects are moved to onMounted to avoid memory leaks during SSR.

  4. nuxt-no-redundant-import-meta - This rule prevents redundant checks for import.meta.server or import.meta.client in Nuxt components that are already scoped by their filename suffix.

  5. Plus a lot more. See the full list here.

  6. An Example ESLint Rule from My Own Nuxt Projects

    I've also found that these custom ESLint rules can steer AI agents away from common mistakes.

    👉 This is a rule that I've added to my own Nuxt projects.

    It enforces correct usage of $fetch and useFetch at the proper places in a Vue component.

    <script setup>
      // ❌ BAD: Using $fetch() in script setup root level
      // Should use useFetch() or useAsyncData() instead
      const _users = await $fetch("/api/users");
    
      // ❌ BAD: Using fetch() in script setup root level
      // Should use useFetch() or useAsyncData() instead
      const response1 = await fetch("/api/data");
      const _data1 = await response1.json();
    
      // ❌ BAD: Using useAsyncData when useFetch would be simpler
      const { data: _comments } = await useAsyncData("comments", () =>
        $fetch("/api/comments")
      );
    
      // Functions and event handlers
      function handleSubmit() {
        // ❌ BAD: Using fetch() in function - should use $fetch()
        fetch("/api/submit", {
          method: "POST",
          body: JSON.stringify({}),
        }).then((response) => response.json());
      }
    
      async function loadData() {
        // ❌ BAD: Using useFetch() in function - should use $fetch()
        const { data: _result } = await useFetch("/api/function-data");
        return _result;
      }
    
      const onClick = async () => {
        // ❌ BAD: Using fetch() in arrow function - should use $fetch()
        const response = await fetch("/api/click-event");
        const _data = await response.json();
    
        // ❌ BAD: Using useAsyncData() in event handler - should use $fetch()
        const { data: _moreData } = await useAsyncData("clickData", () =>
          $fetch("/api/more")
        );
      };
    
      // Lifecycle hooks
      onMounted(async () => {
        // ❌ BAD: Using fetch() in lifecycle hook - should use $fetch()
        const response = await fetch("/api/mounted-data");
        const _data = await response.json();
    
        // ❌ BAD: Using useFetch() in lifecycle hook - should use $fetch()
        const { data: _mountedData } = await useFetch("/api/mounted-fetch");
      });
    
      onUpdated(() => {
        // ❌ BAD: Using fetch() in lifecycle hook - should use $fetch()
        fetch("/api/updated").then((r) => r.json());
    
        // ❌ BAD: Using useAsyncData() in lifecycle hook - should use $fetch()
        useAsyncData("updated", () => $fetch("/api/updated-data"));
      });
    
      // Event handlers
      const handleFormSubmit = async (event) => {
        // ❌ BAD: Using fetch() in event handler - should use $fetch()
        const _result = await fetch("/api/form-submit", {
          method: "POST",
          body: new FormData(event.target),
        });
      };
    </script>

    BTW, I've never written a custom ESLint rule in my life but was able to get a working rule ready in about 10 minutes with the help of AI. So don't let the novelty stop you!

    How to build your own Vue ESLint rule (high-level)

    So you're conviced these custom rules can be helpful and have the confidence to build your own. How do you get started?

    Here’s a high-level walk-through of building custom Vue ESLint rule:

    Step 1: Identify the anti‐pattern

    Scan your codebase, pull up recurring issues (code reviews, bug tickets, refactor comments). Document them: e.g., “We consistently see watchers used when computed could suffice”, “Props mutated directly”, “Template refs declared but never used”.

    Step 2: Setup plugin scaffolding

    Create a new npm package (e.g., eslint-plugin-yourteamname). In package.json, set it up as an ESLint plugin.

    src/
      rules/
        my-rule-1.ts
        my-rule-2.ts
      index.ts

    In index.ts, export the rules:

    import myRule1 from "./rules/my-rule-1";
    import myRule2 from "./rules/my-rule-2";
    
    export default {
      rules: {
        "my-rule-1": myRule1,
        "my-rule-2": myRule2,
      },
      configs: {
        recommended: {
          rules: {
            "my-rule-1": "error",
            "my-rule-2": "warn",
          },
        },
      },
    };

    Step 3: Write rule(s)

    Each rule should export an object with meta and a create(context) function which returns an object with methods that ESLint calls to “visit” nodes while traversing the abstract syntax tree (AST as defined by ESTree) of JavaScript code. Here's the basic structure:

    module.exports = {
      meta: {
        type: "suggestion", // "problem", "suggestion", or "layout"
        docs: {
          description: "Disallow direct mutation of props in Vue components",
          recommended: false,
        },
        fixable: null, // "code", "whitespace", or null
        schema: [], // options schema (mandatory when rule has options)
      },
      create(context) {
        return {
            ReturnStatement: function(node) {
                // at a ReturnStatement node while going down
            },
            // at a function expression node while going up:
            "FunctionExpression:exit": checkLastSegment,
            "ArrowFunctionExpression:exit": checkLastSegment,
            onCodePathStart: function (codePath, node) {
                // at the start of analyzing a code path
            },
            onCodePathEnd: function(codePath, node) {
                // at the end of analyzing a code path
            }
        };
        };
      },
    };

    This part will vary widely depending on the rule you're writing but again, AI can be a big help!

    Step 4: Test the rule

    Provide fixture files (good & bad), and use ESLint's rule tester to ensure the rule behaves as expected.

    Step 6: Publish / integrate

    Publish the plugin as npm package and use it in your project.

    // .eslintrc.js
    plugins: ['yourteamname'],
    rules: {
      'yourteamname/no‐mutate‐props': 'error'
    }

    Also add it to CI and ensure editors pick it up (via ESLint plugin in VSCode etc.)

    Conclusion

    Custom Vue ESLint rules empower you to raise your code quality, enforce team-specific patterns, catch anti-patterns early, and maintain consistency as your project grows. As showcased by Harlan Wilton’s work with eslint-plugin-harlanzw, you can move beyond generic linting and build domain-aware rules that inspect Vue SFCs, Composition API usage, template semantics — tailored to your codebase. With thoughtful design, documentation, and adoption strategy, custom linting becomes part of your developer workflow and toolchain.

Start learning Vue.js for free

Daniel Kelly
Daniel Kelly
Daniel is the lead instructor at Vue School and enjoys helping other developers reach their full potential. He has 10+ years of developer experience using technologies including Vue.js, Nuxt.js, and Laravel.

Comments

Latest Vue School Articles

Video Thumbnail Generator Vue Component with MediaBunny

Video Thumbnail Generator Vue Component with MediaBunny

Learn to build a client-side video thumbnail generator with Vue 3 and MediaBunny. Extract frames, download thumbnails—no servers required.
Daniel Kelly
Daniel Kelly
Passkeys in Nuxt &#8211; The BEST Login UX

Passkeys in Nuxt – The BEST Login UX

Secure, passwordless authentication in Nuxt 4 using passkeys, WebAuthn, and Nuxt Auth Utils for a seamless login experience (with demo!)
Daniel Kelly
Daniel Kelly
VueSchool logo

Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.

More than 200.000 users have already joined us. You are welcome too!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.