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"
}
}
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"
}
}
That shape matters more than any single tool choice:
-
pnpm lintis the fast read-only check. -
pnpm lint:fixis the safe first pass for auto-fixable issues. -
pnpm formatis the fast read-only check, mirroringpnpm lint. -
pnpm format:fixis the one blunt command that normalises the repo, mirroringpnpm lint:fix. -
pnpm formatis also what CI can trust. -
prepareensures Husky is installed onpnpm 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
);
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-emptyallows emptycatchblocks. -
no-undefis switched off for TypeScript files. -
@typescript-eslint/no-empty-object-typeis 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"
}
}
]
};
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
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"
}
And the Husky hook is just:
pnpm lint-staged
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
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"
]
}
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": []
}
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.astrobecame 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
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)