Nx Biome Integration
Integrate Biome into Nx monorepos with deterministic setup, caching, migration from ESLint and Prettier, and plugin-based inferred tasks; use when adding Biome, replacing ESLint/Prettier, tuning cache inputs, or scaling lint and format workflows across projects.
npx skills add github:pantheon-org/tekhne --skill repository-mgmt--nx--biome-integrationNavigation hub for integrating Biome in Nx workspaces with predictable setup and migration.
When to Use
- You need to add Biome lint and format targets in an Nx workspace.
- You are migrating from ESLint or Prettier to Biome.
- You need Nx cache-friendly Biome task configuration.
- You are creating plugin-based inferred tasks for workspace scale.
Principles
- Prefer one root config unless migration constraints require nested overrides.
- Keep lint and format authority singular for each file path.
- Optimize for deterministic CI (read-only checks) and reproducible local fixes.
When Not to Use
- The repository standard is intentionally ESLint/Prettier-only.
- The task is not Nx-related and does not require workspace task integration.
Workflow
- Confirm prerequisites: Nx workspace, Node.js 18+, package manager selected.
- Install Biome and initialize root configuration.
- Add project targets (or plugin inferred tasks) and enable Nx caching inputs.
- Validate lint and format execution on one project, then run across workspace.
- Apply migration strategy (all-at-once or selective nested
biome.json). - Enforce anti-pattern checks before rollout.
Quick Commands
Install and Initialize
npm install --save-dev @biomejs/biome
npx @biomejs/biome init
Expected: root biome.json exists.
Run on One Project
nx biome-lint my-project
nx biome-format my-project
Expected: lint and format targets execute for the selected project.
Run Across Workspace
nx run-many -t biome-lint
nx run-many -t biome-format
Expected: all configured projects run with consistent config and cache behavior.
Run Through Package Script
bun run nx run-many -t biome-lint
Expected: Biome lint runs through package-managed Nx entrypoint.
Reset Nx State After Plugin Changes
nx reset
Expected: daemon cache is cleared and targets refresh.
Example Configuration
Cache inputs for the Biome targets in nx.json:
{
"targetDefaults": {
"biome-lint": {
"cache": true,
"inputs": ["default", "^default", "{workspaceRoot}/biome.json", { "externalDependencies": ["@biomejs/biome"] }]
},
"biome-format": {
"cache": true,
"inputs": ["default", "^default", "{workspaceRoot}/biome.json", { "externalDependencies": ["@biomejs/biome"] }]
}
}
}
Read-only verification in CI, with write mode kept for local fixes:
nx run-many -t biome-lint biome-format
Anti-Patterns
NEVER use nested biome.json for simple workspace-wide setups
WHY: unnecessary config layering increases maintenance and rule drift.
BAD: create biome.json in every project by default.
GOOD: use one root config and add nested configs only for migration or real overrides.
NEVER run ESLint and Biome on the same files without explicit boundary
WHY: dual lint pipelines produce conflicting diagnostics and wasted CI time.
BAD: keep ESLint glob coverage unchanged after adding Biome.
GOOD: define migration boundaries so each file set has one active lint authority.
NEVER omit biome.json and Biome dependency from Nx cache inputs
WHY: cache hits become stale after config/tool version changes.
BAD: cache only default and ^default inputs.
GOOD: include "{workspaceRoot}/biome.json" and externalDependencies: ["@biomejs/biome"].
NEVER auto-enable formatter writes in CI verification jobs
WHY: write-mode formatting in CI mutates files and obscures validation failures.
BAD: run biome format --write in required CI checks.
GOOD: use check/lint mode in CI; reserve write-mode for local or dedicated fix jobs.
NEVER downgrade critical Biome rules to suppress migration noise
WHY: suppressing high-signal rules hides real issues and delays stabilization.
BAD: globally reduce important rules to off during migration.
GOOD: scope temporary exceptions narrowly with documented expiration.
NEVER roll out across the workspace before validating one project
WHY: a misconfigured target or cache input fails everywhere at once and hides the real cause.
BAD: run nx run-many -t biome-lint as the first execution after adding targets.
GOOD: run nx biome-lint my-project and nx biome-format my-project first, then run across the workspace.
NEVER skip nx reset after changing plugin-inferred tasks
WHY: the Nx daemon cache keeps serving the old target graph.
BAD: edit the inference plugin and re-run tasks immediately, expecting new targets.
GOOD: run nx reset so the daemon cache clears and targets refresh.
NEVER start the setup before confirming prerequisites
WHY: a missing Nx workspace, an unsupported Node.js version or an unclear package manager breaks the install in confusing ways.
BAD: run npm install --save-dev @biomejs/biome without checking Node.js 18+ or the package manager in use.
GOOD: confirm the Nx workspace, Node.js 18+ and the package manager first, then install and initialise.
NEVER leave temporary migration exceptions undocumented
WHY: undocumented exceptions become permanent and the migration never finishes.
BAD: add an override with no owner or end date.
GOOD: record each temporary exception with clear removal criteria.
Reference Map
- Deep configuration and cache tuning:
references/biome-configuration-deep-dive.md - Migration from ESLint and Prettier:
references/migration-guide.md - Plugin-based inferred tasks:
references/plugin-patterns.md
References
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Runtime validation via eval scenarios proving the skill actually changes agent behaviour. Requires evals/ directory, instructions.json, ≥3 scenarios, and ≥80% instruction coverage in summary.json.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
Expert-only knowledge the AI couldn't derive on its own — domain-specific patterns, production gotchas, decision frameworks, and non-obvious tool behaviour. High scores mean <5% redundancy with what the model already knows.
A clear philosophy plus numbered step-by-step workflows with explicit entry/exit points. Should answer: what is the core principle, how to execute it, and when (and when not) to apply it.
Explicit NEVER/WHY/consequence triplets with side-by-side bad/good examples. Generic warnings score low — concrete failure modes with real impact (security, correctness, performance) score high.
Proper frontmatter, single-task focus, activation keywords, and cross-harness portability. Skills must work across 40+ agentic harnesses without hardcoded paths or agent-specific references.
SKILL.md as a <100-line navigation hub pointing to focused references/ files. Content should be lazy-loaded on demand, not front-loaded. Includes actionable 'When to Use' conditions per reference.
Appropriate rigidity for the skill type: strict rules for critical/security skills, balanced steps for process skills, flexible options for tool skills. Mismatched calibration (too rigid or too loose) reduces score.
Rich activation keywords and trigger scenarios in the description so the skill fires when needed. Best descriptions read like an exhaustive list of the tasks and concepts that should invoke this skill.
Copy-paste-ready code, complete runnable examples, real file paths and commands. Pseudocode and abstract descriptions score low — concrete, executable content scores high.
No eval scenarios available.
Biome Configuration Deep Dive for Nx
Use this guide when you need advanced configuration details beyond the SKILL hub.
Root Configuration Baseline
Create and maintain one root biome.json as the default authority:
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
}
}
Nx Target Defaults for Cache Stability
Ensure lint and format targets include config and dependency inputs:
{
"targetDefaults": {
"biome-lint": {
"cache": true,
"inputs": [
"default",
"^default",
"{workspaceRoot}/biome.json",
{ "externalDependencies": ["@biomejs/biome"] }
]
},
"biome-format": {
"cache": true,
"inputs": [
"default",
"^default",
"{workspaceRoot}/biome.json",
{ "externalDependencies": ["@biomejs/biome"] }
]
}
}
}
Advanced Rule Tuning Strategy
- Keep
recommended: trueas baseline. - Override only rules that show recurring false positives for your repository.
- Prefer narrow path-scoped exceptions over global relaxations.
- Record reason + expiration for temporary suppressions.
Example scoped override (temporary migration guardrail):
{
"overrides": [
{
"includes": ["apps/legacy/**"],
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "off"
}
}
}
}
]
}
Performance Optimization Tips
- Run
nx run-many -t biome-lintinstead of ad hoc per-project loops. - Use Nx affected workflows in CI to limit scope.
- Reset daemon cache (
nx reset) after plugin or target definition changes. - Keep plugin inference logic lightweight; avoid expensive filesystem traversal.
Verification Checklist
biome.jsonexists at workspace root.biome-lintandbiome-formatare cache-enabled.- Cache inputs include root config and external Biome dependency.
- CI verification runs non-mutating checks.
ESLint/Prettier to Biome Migration Guide (Nx)
Use this guide to migrate safely without lint conflicts or CI instability.
Migration Modes
Mode A: Big-Bang Migration
Use when workspace is small or consistency is urgent.
- Add Biome and initialize root config.
- Replace ESLint/Prettier targets with Biome targets.
- Remove old lint/format scripts from CI pipelines.
- Run workspace-wide lint and format checks.
Mode B: Selective Migration
Use when workspace is large or has legacy areas.
- Keep root
biome.jsonas baseline. - Add nested
biome.jsononly to opted-in projects. - Configure plugin inference pattern to detect project configs.
- Keep ESLint for non-migrated projects until completion.
ESLint to Biome Steps
- Inventory existing ESLint targets and file globs.
- Define Biome coverage boundaries (which projects/files move first).
- Ensure no file path is linted by both tools simultaneously.
- Port essential policy rules first; defer stylistic edge cases.
- Track temporary rule exceptions with clear removal criteria.
Prettier to Biome Steps
- Disable Prettier format target for migrated projects.
- Use
biome formatfor local write workflows. - In CI, use non-write checks to validate formatting.
- Remove duplicate formatting hooks once Biome is authoritative.
Common Pitfalls and Fixes
Pitfall: Duplicate lint errors in CI
Cause: ESLint and Biome both run on same files.
Fix: split coverage by project or glob and decommission old target incrementally.
Pitfall: Cache serving stale results
Cause: missing biome.json or package dependency in cache inputs.
Fix: include root config and externalDependencies in targetDefaults.
Pitfall: Formatter rewrites in validation jobs
Cause: using --write in CI.
Fix: separate check vs write workflows; keep CI read-only.
Exit Criteria
- Biome is single lint/format authority for migrated scope.
- CI is deterministic and non-mutating in verification stages.
- No temporary migration exceptions remain undocumented.
NX Plugin Creation Patterns
Advanced patterns for creating custom NX plugins with inferred tasks.
Plugin Anatomy
Basic Structure
import {
CreateNodesContextV2,
createNodesFromFiles,
CreateNodesV2,
} from '@nx/devkit';
import { dirname } from 'path';
export interface MyPluginOptions {
// Configuration options
}
export const createNodesV2: CreateNodesV2<MyPluginOptions> = [
// File pattern to match
'**/config-file-pattern',
async (configFiles, options, context) => {
return await createNodesFromFiles(
(configFile, options, context) =>
createNodesInternal(configFile, options, context),
configFiles,
options,
context
);
},
];
async function createNodesInternal(
configFilePath: string,
options: MyPluginOptions | undefined,
context: CreateNodesContextV2
) {
const root = dirname(configFilePath);
// Return project configuration
return {
projects: {
[root]: {
targets: {
// Target definitions
},
},
},
};
}
File Pattern Selection
Choose the right file pattern for your use case:
1. Configuration-Based Discovery
Pattern: **/tool-config.json
Use when the tool requires explicit configuration files.
export const createNodesV2: CreateNodesV2<Options> = [
'**/biome.json', // Only projects with biome.json
async (configFiles, options, context) => {
// ...
},
];
Benefits:
- Explicit opt-in (teams control which projects use the tool)
- Supports nested configs for selective adoption
- Clear project boundaries
Example: Biome, Prettier, Webpack config-based tools
2. Package.json Discovery
Pattern: **/package.json
Use for tools that work with any JavaScript/TypeScript project.
export const createNodesV2: CreateNodesV2<Options> = [
'**/package.json',
async (configFiles, options, context) => {
// Filter based on package.json content if needed
const pkg = readJsonFile(configFilePath);
if (!pkg.scripts?.['my-script']) {
return {}; // Skip this project
}
// ...
},
];
Benefits:
- Works with npm workspaces
- Automatically covers all projects
- Can filter based on dependencies or scripts
Example: Jest, ESLint, generic tooling
3. Project.json Discovery
Pattern: **/project.json
Use when not using npm workspaces (legacy NX projects).
export const createNodesV2: CreateNodesV2<Options> = [
'**/project.json',
async (configFiles, options, context) => {
// Read existing config and merge
const projectConfig = readJsonFile(configFilePath);
return {
projects: {
[root]: {
...projectConfig,
targets: {
...projectConfig.targets,
'my-target': {
// Add your target
},
},
},
},
};
},
];
Target Definition Patterns
1. Simple Command Target
{
targets: {
'my-lint': {
command: 'npx my-tool lint {projectRoot}',
cache: true,
inputs: ['default', '{workspaceRoot}/my-tool.config.js'],
},
},
}
2. Executor-Based Target
{
targets: {
'my-build': {
executor: 'tools/my-plugin:build',
options: {
outputPath: 'dist/{projectRoot}',
},
cache: true,
outputs: ['{options.outputPath}'],
},
},
}
3. Conditional Targets
async function createNodesInternal(
configFilePath: string,
options: MyPluginOptions | undefined,
context: CreateNodesContextV2
) {
const root = dirname(configFilePath);
const config = readJsonFile(configFilePath);
const targets: Record<string, any> = {};
// Only add build if needed
if (config.build) {
targets['build'] = {
command: 'npx my-tool build {projectRoot}',
cache: true,
};
}
// Always add lint
targets['lint'] = {
command: 'npx my-tool lint {projectRoot}',
cache: true,
};
return {
projects: {
[root]: { targets },
},
};
}
Caching Configuration
Input Patterns
{
cache: true,
inputs: [
// Project source files
'default',
// Dependency source files
'^default',
// Specific config files
'{workspaceRoot}/my-tool.config.js',
'{projectRoot}/my-tool.json',
// External dependencies
{
externalDependencies: ['@my-tool/cli', '@my-tool/core'],
},
// Environment variables
{
env: 'MY_TOOL_ENV',
},
],
}
Output Patterns
{
cache: true,
outputs: [
// Static paths
'{workspaceRoot}/dist/{projectName}',
// Dynamic paths from options
'{options.outputPath}',
// Multiple outputs
'{projectRoot}/dist',
'{projectRoot}/build',
],
}
Advanced Patterns
1. Plugin Options
export interface MyPluginOptions {
exclude?: string[];
targetName?: string;
}
async function createNodesInternal(
configFilePath: string,
options: MyPluginOptions | undefined,
context: CreateNodesContextV2
) {
const root = dirname(configFilePath);
// Use options to customize behavior
const exclude = options?.exclude || [];
if (exclude.some(pattern => root.includes(pattern))) {
return {}; // Skip excluded projects
}
const targetName = options?.targetName || 'my-default-target';
return {
projects: {
[root]: {
targets: {
[targetName]: {
// Target definition
},
},
},
},
};
}
Configure in nx.json:
{
"plugins": [
{
"plugin": "tools/my-plugin",
"options": {
"exclude": ["e2e", "integration"],
"targetName": "custom-lint"
}
}
]
}
2. Filtering Projects
async function createNodesInternal(
configFilePath: string,
options: MyPluginOptions | undefined,
context: CreateNodesContextV2
) {
const root = dirname(configFilePath);
// Skip root config
if (root === '.') {
return {};
}
// Only process specific project types
const pkg = readJsonFile(configFilePath);
if (pkg.projectType !== 'application') {
return {};
}
// Check for specific dependencies
const hasDependency =
pkg.dependencies?.['@my-tool/core'] ||
pkg.devDependencies?.['@my-tool/core'];
if (!hasDependency) {
return {};
}
// Process project
return {
projects: {
[root]: {
// ...
},
},
};
}
3. Reading Configuration
import { readJsonFile } from '@nx/devkit';
import { existsSync, readFileSync } from 'fs';
import { join } from 'path';
async function createNodesInternal(
configFilePath: string,
options: MyPluginOptions | undefined,
context: CreateNodesContextV2
) {
const root = dirname(configFilePath);
// Read JSON config
const config = readJsonFile(join(context.workspaceRoot, root, 'my-config.json'));
// Read text config
const textConfigPath = join(context.workspaceRoot, root, '.myrc');
const textConfig = existsSync(textConfigPath)
? readFileSync(textConfigPath, 'utf-8')
: null;
// Use config to customize targets
return {
projects: {
[root]: {
targets: {
'my-target': {
command: `npx my-tool ${config.subcommand} {projectRoot}`,
},
},
},
},
};
}
Testing Plugins
Development Workflow
- Bypass daemon cache during development:
NX_DAEMON=false nx run-many -t my-target
- Reset cache between changes:
nx reset
- View generated targets:
nx show project my-project --web
Debugging
Add console logs in plugin (temporary):
async function createNodesInternal(
configFilePath: string,
options: MyPluginOptions | undefined,
context: CreateNodesContextV2
) {
console.log('Processing:', configFilePath);
console.log('Options:', options);
const root = dirname(configFilePath);
console.log('Project root:', root);
// ... rest of logic
}
Run with verbose output:
NX_VERBOSE_LOGGING=true nx run-many -t my-target
Performance Tips
- Use createNodesFromFiles: Processes files in parallel
- Minimize I/O: Only read files you actually need
- Cache expensive computations: Store results in plugin state
- Filter early: Return
{}as soon as you know a project should be skipped
Complete Example: Multi-Tool Plugin
import {
CreateNodesContextV2,
createNodesFromFiles,
CreateNodesV2,
readJsonFile,
} from '@nx/devkit';
import { dirname, join } from 'path';
import { existsSync } from 'fs';
export interface DevToolsPluginOptions {
lintTarget?: string;
formatTarget?: string;
testTarget?: string;
}
export const createNodesV2: CreateNodesV2<DevToolsPluginOptions> = [
'**/package.json',
async (configFiles, options, context) => {
return await createNodesFromFiles(
(configFile, options, context) =>
createNodesInternal(configFile, options, context),
configFiles,
options,
context
);
},
];
async function createNodesInternal(
configFilePath: string,
options: DevToolsPluginOptions | undefined,
context: CreateNodesContextV2
) {
const root = dirname(configFilePath);
// Skip root
if (root === '.') {
return {};
}
const pkg = readJsonFile(configFilePath);
const targets: Record<string, any> = {};
// Add lint target if biome.json exists
const biomeConfigPath = join(context.workspaceRoot, root, 'biome.json');
if (existsSync(biomeConfigPath)) {
const targetName = options?.lintTarget || 'lint';
targets[targetName] = {
command: 'npx @biomejs/biome lint {projectRoot}',
cache: true,
inputs: [
'default',
'{workspaceRoot}/biome.json',
'{projectRoot}/biome.json',
{ externalDependencies: ['@biomejs/biome'] },
],
};
}
// Add format target if prettier config exists
const prettierConfigPath = join(context.workspaceRoot, root, '.prettierrc');
if (existsSync(prettierConfigPath)) {
const targetName = options?.formatTarget || 'format';
targets[targetName] = {
command: 'npx prettier --write {projectRoot}',
cache: true,
inputs: [
'default',
'{workspaceRoot}/.prettierrc',
'{projectRoot}/.prettierrc',
{ externalDependencies: ['prettier'] },
],
};
}
// Add test target if jest config exists
if (pkg.scripts?.test || pkg.jest) {
const targetName = options?.testTarget || 'test';
targets[targetName] = {
command: 'jest',
cache: true,
inputs: [
'default',
'^default',
'{workspaceRoot}/jest.config.js',
{ externalDependencies: ['jest'] },
],
outputs: ['{projectRoot}/coverage'],
};
}
// Only return project config if we found any targets
if (Object.keys(targets).length === 0) {
return {};
}
return {
projects: {
[root]: { targets },
},
};
}