DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on Originally published at sourcier.uk

ESLint, Prettier, and Husky for an Astro Site

Original post: ESLint, Prettier, and Husky for an Astro Site

Series: Part of How this blog was built — a series of posts on every decision that shaped this site.

Code quality tooling should disappear into the background. The point is not to spend time thinking about ESLint or Prettier. The point is to have Astro components, TypeScript, Netlify functions, and utility scripts all follow the same rules with as little ceremony as possible.

For this site I wanted one setup that worked in four places at once: in the editor, in a pre-commit hook, as explicit local commands, and inside GitHub Actions CI. That meant wiring together ESLint, Prettier, Husky, lint-staged, EditorConfig, and a small amount of VS Code project config into one workflow.

What I wanted from the setup

The brief was simple:

  • Lint Astro, JavaScript, and TypeScript from one command.
  • Format the whole repository consistently with Prettier.
  • Run quick checks before each commit.
  • Keep editor behaviour aligned across machines.
  • Fail CI if the checked-in code drifts from the agreed style.

That translated into five pieces:

  • ESLint for code-quality rules.
  • Prettier for formatting.
  • Husky to install a pre-commit hook.
  • lint-staged so the hook only touches staged files.
  • EditorConfig so basic whitespace rules are consistent even before Prettier runs.

The packages

The core dependencies that landed in package.json were:

{
  "devDependencies": {
    "@eslint/js": "^10.0.1",
    "@typescript-eslint/parser": "^8.68.0",
    "eslint": "^10.9.1",
    "eslint-config-prettier": "^10.1.8",
    "eslint-plugin-astro": "^1.7.0",
    "globals": "^17.11.0",
    "husky": "^9.1.7",
    "lint-staged": "^16.4.0",
    "prettier": "^3.9.6",
    "prettier-plugin-astro": "^0.14.1",
    "typescript-eslint": "^8.68.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details matter here.

First, Astro needs both ESLint support and a Prettier plugin. Without eslint-plugin-astro and prettier-plugin-astro, the setup would only understand the JavaScript and TypeScript around the site, not the .astro files that make up most of the UI.

Second, this uses ESLint flat config rather than the older .eslintrc format. Flat config is where ESLint has clearly landed, so it made sense to start there instead of building on the legacy configuration model.

The npm scripts

I wanted one small set of commands that cover local development, quick fixes, and CI/build verification:

{
  "scripts": {
    "lint": "eslint \"**/*.{js,mjs,cjs,ts,mts,cts,astro}\"",
    "lint:fix": "pnpm lint --fix",
    "format": "prettier . --check",
    "format:fix": "prettier . --write",
    "prepare": "husky"
  }
}
Enter fullscreen mode Exit fullscreen mode

That shape matters more than any single tool choice:

  • pnpm lint is the fast read-only check.
  • pnpm lint:fix is the safe first pass for auto-fixable issues.
  • pnpm format is the fast read-only check, mirroring pnpm lint.
  • pnpm format:fix is the one blunt command that normalises the repo, mirroring pnpm lint:fix.
  • pnpm format is also what CI can trust.
  • prepare ensures Husky is installed on pnpm install.

Once those are in place, CI can use exactly the same checks developers run locally.

ESLint with Astro and TypeScript

The lint config ended up in eslint.config.js:

import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import astro from "eslint-plugin-astro";
import globals from "globals";
import tseslint from "typescript-eslint";

export default tseslint.config(
  {
    ignores: [
      ".astro/**",
      ".netlify/**",
      ".tmp/**",
      "collections/posts/**",
      "dist/**",
      "node_modules/**",
      "public/pagefind/**",
      "public/post-images/**",
      "public/search-thumbnails/**"
    ]
  },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  ...astro.configs.recommended,
  {
    files: ["**/*.{js,mjs,cjs,ts,mts,cts}"],
    languageOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
      globals: {
        ...globals.node
      }
    },
    rules: {
      "no-empty": ["error", { "allowEmptyCatch": true }]
    }
  },
  {
    files: ["**/*.{ts,mts,cts}"],
    rules: {
      "no-undef": "off"
    }
  },
  {
    files: ["**/*.d.ts"],
    rules: {
      "@typescript-eslint/no-empty-object-type": "off"
    }
  },
  {
    files: [
      "public/**/*.{js,mjs,cjs,ts,mts,cts}",
      "src/scripts/**/*.{js,mjs,cjs,ts,mts,cts}"
    ],
    languageOptions: {
      globals: {
        ...globals.browser
      }
    }
  },
  {
    files: ["netlify/edge-functions/**/*.{js,mjs,cjs,ts,mts,cts}"],
    languageOptions: {
      globals: {
        ...globals.browser,
        ...globals.serviceworker
      }
    }
  },
  eslintConfigPrettier
);
Enter fullscreen mode Exit fullscreen mode

A few points are worth calling out.

Ignore the right generated files

This site generates build output, search indexes, copied post assets, and Astro internals. Linting generated directories just creates noise and slows everything down, so the first job of the config is to ignore them aggressively. It also ignores collections/posts/**, because the blog content lives in a separate repository with its own workflow.

Use environment-specific globals

The site has more than one runtime:

  • Node-based utility scripts.
  • Browser-side code in public/ and client scripts.
  • Netlify edge functions with browser and service worker globals.

The globals package made it possible to describe those environments precisely instead of sprinkling /* global */ comments around the codebase.

Use pragmatic rule overrides

There are a few targeted exceptions in the config:

  • no-empty allows empty catch blocks.
  • no-undef is switched off for TypeScript files.
  • @typescript-eslint/no-empty-object-type is switched off in declaration files.

None of those change the overall strictness much. They just stop the linter from arguing with patterns that are legitimate in this codebase.

Let Prettier own formatting

eslint-config-prettier switches off rules that fight Prettier. That keeps ESLint focused on correctness and maintainability rather than arguing about whitespace.

Prettier with Astro support

The Prettier config is intentionally small:

/** @type {import("prettier").Config} */
export default {
  plugins: ["prettier-plugin-astro"],
  overrides: [
    {
      files: "*.astro",
      options: {
        parser: "astro"
      }
    }
  ]
};
Enter fullscreen mode Exit fullscreen mode

That was enough to make .astro files first-class citizens in the formatting pass.

I also added a .prettierignore for generated output, copied assets, and the nested content repository:

.astro
.netlify
.tmp
collections/posts
deno.lock
.devcontainer/devcontainer-lock.json
dist
node_modules
public/pagefind
public/post-images
public/search-thumbnails
public/talks/*.pptx
pnpm-lock.yaml
Enter fullscreen mode Exit fullscreen mode

That keeps Prettier away from things like dist/, Pagefind output, copied post images, lockfiles, generated PowerPoint decks, and the separate content repo that is cloned into the site at build time.

Pre-commit checks without punishing every commit

Running the full repository on every commit is unnecessary friction. The right compromise is lint-staged:

{
  "*.{js,mjs,cjs,ts,mts,cts,astro}": ["eslint --fix", "prettier --write"],
  "*.{json,md,mdx,scss,css,yml,yaml,html}": "prettier --write"
}
Enter fullscreen mode Exit fullscreen mode

And the Husky hook is just:

pnpm lint-staged
Enter fullscreen mode Exit fullscreen mode

This is the part that makes the setup feel lightweight in practice. If a commit touches two files, only those two files are checked and rewritten. The broader commands still exist for CI and for deliberate repo-wide cleanup, but day-to-day commits stay fast.

Editor consistency with EditorConfig

Prettier fixes most style issues after the fact. .editorconfig reduces how many you create in the first place:

root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false
Enter fullscreen mode Exit fullscreen mode

The Markdown exception is deliberate. Trailing whitespace can matter in Markdown, so it is better not to strip it blindly.

VS Code integration

To surface the same rules inside the editor, I added a small workspace config:

{
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "astro",
    "typescript",
    "typescriptreact"
  ]
}
Enter fullscreen mode Exit fullscreen mode

And to make the recommended tooling obvious for anyone opening the repo for the first time:

{
  "recommendations": [
    "astro-build.astro-vscode",
    "dbaeumer.vscode-eslint",
    "esbenp.prettier-vscode"
  ],
  "unwantedRecommendations": []
}
Enter fullscreen mode Exit fullscreen mode

That is enough to make ESLint diagnostics show up in the right file types and to nudge contributors toward the same Astro, ESLint, and Prettier extensions.

Handling vendor code cleanly

One implementation detail needed special handling: a third-party analytics bootstrap snippet in BaseLayout.astro.

Minified vendor code is exactly the sort of thing formatters hate. Rather than teaching Prettier and ESLint to make exceptions for a large opaque block, I moved the bootstrap into public/scripts/posthog-bootstrap.js and kept the layout responsible only for loading it.

That ended up being a better design anyway:

  • BaseLayout.astro became readable again.
  • Prettier could format the layout normally.
  • The analytics bootstrap stayed vendor-like and isolated.
  • The production-only script tag could pass configuration through data- attributes instead of embedding a large opaque block inline.

The lesson there is simple: if a code-quality tool is constantly fighting a file, the problem may be the file boundary, not the tool.

Repository-wide checks in CI

The pre-commit hook only ever looks at staged files, so the backstop for everything else is a dedicated quality job in GitHub Actions:

jobs:
  quality:
    name: Format, Lint & Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: ./.github/actions/setup
      - run: pnpm format
      - run: pnpm lint
      - run: pnpm typecheck:functions
      - run: pnpm test
Enter fullscreen mode Exit fullscreen mode

That job runs on every push and pull request, before anything gets built or deployed. The production build job then waits on quality succeeding, so a formatting or lint regression blocks the deploy rather than landing silently.

Netlify itself never builds or lints anything here. netlify.toml has no [build].command at all: CI runs astro build, then deploys the prebuilt dist/ with netlify deploy --no-build. That keeps the contract simple: one GitHub Actions job owns formatting, linting, type-checking, and tests. The same two commands, pnpm lint and pnpm format, run identically whether triggered from a laptop or from CI.

That matters for one reason above all others: CI failures stop being surprising. If pnpm lint and pnpm format pass locally, the same stage should pass in GitHub Actions.

What this setup gives the project

The finished setup is not exotic, but it does exactly what I want:

  • New code gets consistent formatting automatically.
  • Obvious lint issues are caught before they hit the repository.
  • Commits stay quick because only staged files are auto-fixed.
  • CI uses the same commands as local development.
  • Astro, TypeScript, scripts, and Netlify functions all sit under one predictable workflow.

More importantly, the repo now has a single answer to style questions: run the tools.

That is the real value of this kind of setup. It reduces low-value discussion, catches small problems early, and makes a mixed codebase feel a lot more orderly.

ESLint handles code quality, Prettier handles formatting, Husky and lint-staged keep commits tidy, EditorConfig smooths editor defaults, VS Code surfaces the checks early, and GitHub Actions enforces the same rules in CI. Once those pieces are wired together, the whole system mostly fades into the background, which is exactly what you want.

If you're setting up something similar on an Astro project, or you've landed on a different combination of tools for the same problem, drop a comment below or subscribe via the form at the end of this page to catch future posts in this series.

Top comments (0)