* notes / biome
De ESLint a Ultracite con Biome
— Del tándem ESLint + Prettier a Biome configurado con la receta opinada de Ultracite, midiendo tiempos y dolores en un monorepo Next.js.
Hacía años que arrastraba el mismo ritual al arrancar cualquier proyecto: eslint, @typescript-eslint, una docena de plugins (react, react-hooks, import, jsx-a11y, unicorn...), prettier, eslint-config-prettier, eslint-plugin-prettier, un .eslintrc.cjs cada vez más barroco y, finalmente, esa molesta sensación de que el editor tardaba dos segundos en responder cada vez que guardaba un archivo. Un buen día, harto de auditar dependencias transitivas y de discutir si la regla import/order debía considerar los alias del tsconfig, decidí probar Biome en un monorepo de tamaño medio. Para evitar el síndrome del lienzo en blanco, opté por Ultracite, una receta opinada que envuelve Biome con reglas estrictas pensadas para React y Next.js. El resultado, tras dos semanas de uso intensivo, justifica este artículo.
Contexto: qué resuelven Biome y Ultracite
Biome es un toolchain escrito en Rust que unifica formateo y linting en un único binario, con una configuración declarativa centralizada en biome.jsonc. No se trata simplemente de "ESLint más rápido": es una reinvención del modelo. El parser es propio (no depende de @typescript-eslint/parser), las reglas se compilan al binario, y no existe el concepto de plugins externos como tales; los analizadores conviven dentro del mismo árbol. Esto explica tanto su velocidad como la principal crítica que recibe: la paridad con el ecosistema de plugins de ESLint todavía no es total.
Ultracite, por su parte, no es un linter, sino una preset opinada sobre Biome. Activa el grueso de reglas recomendadas, ajusta el formateador a convenciones modernas (comillas dobles, punto y coma obligatorio, ancho de línea 100), añade integraciones con TypeScript estrictas y, sobre todo, suprime la tentación de discutir reglas en revisiones de código. Su filosofía es deliberadamente cero-configuración: instalas, ejecutas y dejas que la herramienta decida.
La diferencia, lejos de ser anecdótica, transformó mi flujo de trabajo. El comando lint:fix pasó de ser un trámite incómodo que ejecutaba antes de cenar a una operación que ya ni siquiera percibo: ocurre en menos de un segundo y no me obliga a salir del estado de concentración.
Implementación: del comando inicial al biome.jsonc final
El arranque no admite mayor ceremonia. Desde la raíz del repositorio:
bunx ultracite initEsta orden detecta tu gestor de paquetes (Bun, pnpm, npm o Yarn), instala Biome como dependencia de desarrollo, genera un biome.jsonc con la configuración recomendada y, asimismo, añade los scripts canónicos al package.json. Cabe destacar que Ultracite también escribe ajustes para tu editor (.vscode/settings.json y .cursor/settings.json) para que el formateo al guardar funcione sin fricciones.
El biome.jsonc resultante tiene una pinta similar a la siguiente:
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"extends": ["ultracite"],
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"ignore": [".next", "dist", "build", "coverage", "node_modules"]
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"linter": {
"rules": {
"recommended": true,
"correctness": {
"noUnusedImports": "error",
"useExhaustiveDependencies": "warn"
},
"style": {
"useImportType": "error",
"noNonNullAssertion": "warn"
},
"nursery": {
"useSortedClasses": {
"level": "warn",
"options": { "functions": ["clsx", "cn", "twMerge"] }
}
}
}
}
}La regla useSortedClasses, equivalente moral al plugin tailwindcss de ESLint, justifica por sí sola haber dado el salto: ordena las clases de Tailwind respetando los wrappers clsx, cn y twMerge, sin necesidad de plugin externo.
El diff en los scripts del package.json es igual de elocuente:
{
"scripts": {
- "lint": "eslint . --ext .ts,.tsx",
- "lint:fix": "eslint . --ext .ts,.tsx --fix",
- "format": "prettier --write \"**/*.{ts,tsx,md,mdx,json}\"",
- "format:check": "prettier --check \"**/*.{ts,tsx,md,mdx,json}\""
+ "lint": "biome lint .",
+ "lint:fix": "biome check --write .",
+ "format": "biome format --write .",
+ "format:check": "biome format ."
}
}Para la integración continua, la cuestión se reduce a una sola línea adicional en el flujo de GitHub Actions:
- name: Lint + format
run: bunx biome ci .El subcomando biome ci ejecuta linter y formateador en modo verificación, devuelve un código de salida no nulo si encuentra problemas y emite informes en formato GitHub para anotar el diff directamente sobre el pull request. Por consiguiente, lo que antes requería dos pasos (lint y format:check) ahora se resuelve en uno solo, con feedback más rico.
Hallazgos: lo bueno, lo regular y las grietas
La impresión global es ampliamente positiva, pero conviene matizar.
| Aspecto | Valoración |
|---|---|
| Velocidad | Excepcional, 20-40x más rápido en los proyectos medidos. |
| Configuración | Un único archivo, sin plugins ni capas de presets enfrentadas. |
| Cobertura React/Next.js | Sólida con Ultracite; sustituye react-hooks y jsx-a11y razonablemente bien. |
| Plugins faltantes | eslint-plugin-testing-library, eslint-plugin-vitest, reglas de import/no-cycle. |
| Integraciones de editor | Excelentes en VS Code y Cursor; correctas en Zed; rezagadas en Sublime. |
| Mensajes de error | Más claros que ESLint; con sugerencias accionables y diff inline. |
El punto delicado es la paridad de plugins. Si dependes de reglas muy específicas (por ejemplo, una norma corporativa que prohíbe importar desde rutas relativas más allá de dos niveles, o validaciones de cypress) probablemente no las encuentres todavía en Biome. La estrategia que adopté fue convivencia transitoria: Biome para el grueso, y un ESLint reducidísimo (cuatro reglas, sin Prettier) ejecutado únicamente en CI para esas comprobaciones huérfanas. No es una solución elegante, pero permite migrar sin sacrificar criterios.
Otro detalle nada trivial: la regla useExhaustiveDependencies de Biome es algo más estricta que su contraparte en react-hooks, lo cual descubrió varios bugs latentes en mis useEffect. No es queja, es elogio.
Closure
Migrar a Biome con Ultracite no es una decisión puramente técnica; es también una declaración sobre cómo queremos invertir nuestra atención. El ecosistema clásico de ESLint + Prettier funcionaba, pero exigía una dosis creciente de mantenimiento meta: configurar, deduplicar, deshacer conflictos entre plugins. Biome compra simplicidad a cambio de cierta rigidez, y Ultracite radicaliza esa apuesta: tú escribes código, la herramienta decide el estilo. Para un equipo pequeño o un proyecto unipersonal, el intercambio me parece, sin matices, ventajoso. Para una organización con políticas muy idiosincráticas, la migración requiere conversaciones previas y, quizá, esperar a que la paridad de plugins madure un par de versiones más. En mi caso, no obstante, no veo razón para volver atrás.