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:
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!
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.
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.
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 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
.
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:
“I wrote some Vue ESLint rules to avoid common anti-patterns that I’ve come across. Thoughts?” and then provided a few examples of the rules he's built.
The three he included in the tweet were:
This rules prevents naming a function with the use
prefix if it's not using the composition API to keep up with reactive state.
This rules prevents accessing a ref in the template since it's already "unwrapped".
This rule prevents using toRefs
since they're already reactive and can be reactively destructured from defineProps as of Vue 3.5.
These are just a few of the available rules.
Peaking into the plugin itself, there are a lot of other interesting rules that are worth checking out.
vue-no-passing-refs-as-props
- Avoid passing refs as props. Pass the unwrapped value using ref.value or use reactive() instead.
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.
nuxt-no-side-effects-in-setup
- Enforce that side effects are moved to onMounted to avoid memory leaks during SSR.
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.
Plus a lot more. See the full list here.
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!
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:
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”.
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",
},
},
},
};
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!
Provide fixture files (good & bad), and use ESLint's rule tester to ensure the rule behaves as expected.
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.)
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.
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!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.