I’ve been using Biome for linting and formatting in several TypeScript and React projects. I started with a long list of explicit rules. Over time, most of those setups moved to Ultracite presets, with a smaller set of preferences and framework exceptions layered on top.
The configurations aren’t identical across every project. My Next.js templates, React starters, and application repositories need different exceptions. What they share is a preference for consistent formatting, useful lint rules, and checks that run before changes reach review.
Here’s the baseline I use, how it relates to the Biome task in my xtarterize tooling, and where I adapt it.
Why Biome?
Biome combines formatting, linting, and import organization in one tool. I like having those responsibilities configured together instead of maintaining separate formatter and linter setups.
Ultracite supplies shared presets on top of Biome. That lets me inherit a maintained set of rules and keep my project configuration focused on the differences I care about.
This is a choice I make per project. Some of my repositories use standalone Biome rules, and others use different tooling. The xtarterize Biome task also checks for existing ESLint, Oxlint, and Oxfmt setups before deciding whether it applies.
The Configuration
This is my current biome.json (v2 schema):
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["ultracite/biome/core", "ultracite/biome/react"],
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": [
"src/**/*",
"*.config.ts",
"!**/*.css",
"!**/*.d.ts",
"!.agents",
"!.claude"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"complexity": {
"noExcessiveLinesPerFunction": {
"level": "error",
"options": {
"maxLines": 60
}
}
},
"style": {
"noExcessiveLinesPerFile": {
"level": "error",
"options": {
"maxLines": 500,
"skipBlankLines": true
}
},
"useBlockStatements": "error",
"useConsistentArrayType": {
"level": "error",
"options": {
"syntax": "generic"
}
},
"useConsistentTypeDefinitions": "off",
"useFilenamingConvention": {
"level": "error",
"options": {
"filenameCases": ["kebab-case"]
}
}
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
},
"overrides": [
{
"includes": ["*.test.ts", "*.test.tsx", "*.spec.ts", "*.spec.tsx"],
"linter": {
"rules": {
"nursery": {
"useConsistentTestIt": {
"level": "error",
"options": {
"function": "it",
"withinDescribe": "test"
}
}
}
}
}
}
],
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
[":URL:", ":NODE:", ":PACKAGE:"],
":BLANK_LINE:",
[":ALIAS:"],
":BLANK_LINE:",
[":PATH:"]
]
}
}
}
}
}
}
What This Setup Enforces
Consistent Formatting and Types
I use spaces for indentation, single quotes for JavaScript and TypeScript strings, and generic array syntax such as Array<T> instead of T[].
The array choice is stylistic. Consistency matters more to me than either notation. I leave useConsistentTypeDefinitions off, so this configuration doesn’t force one convention for choosing between type and interface.
Linting doesn’t replace TypeScript’s compiler checks. I still run the project’s type-checking command separately.
Limits That Encourage Smaller Units
The explicit limits are 60 lines per function and 500 lines per file, with blank lines excluded from the file count. These rules give me a signal to reconsider a large unit of code. They don’t prove that a shorter function or file is well designed.
Import Organization
Biome’s import organizer puts URL imports, Node built-ins, and packages in the first group, aliases in the second, and path imports in the third, with blank lines between groups.
Those first three kinds are grouped together; this configuration doesn’t insert a blank line between each one. Applying the organization requires a fix command or an editor action. A check command reports differences without writing them.
Running It Locally and in CI
For the versions used to verify this example, install both packages as development dependencies:
pnpm add -D -E @biomejs/biome@2.5.9 ultracite@7.10.7
Then add scripts to package.json:
{
"scripts": {
"lint": "ultracite check",
"lint:fix": "ultracite fix"
}
}
Run the check locally or in CI:
pnpm lint
Apply fixes locally:
pnpm lint:fix
Using scripts runs the installed version rather than fetching a different release for each invocation. Direct Biome commands such as pnpm exec biome check and pnpm exec biome check --write are also available when working with Biome itself.
Before replacing an existing ESLint and Prettier setup, check the rules, plugins, and file types it relies on. Biome provides migration commands, but the migration still needs verification against the project’s requirements.
The Result
The benefit I care about is a repeatable baseline: formatting, import organization, and common lint checks run automatically, while project-specific exceptions remain visible in the configuration.
For me, the useful balance is a shared preset with a small, deliberate set of overrides. It keeps routine checks consistent while leaving code review focused on behavior, design, and the decisions a linter can’t make.
With this config, my CI runs lint and format checks in under 2 seconds for most projects. The codebase stays consistent without requiring constant human enforcement. And code reviews focus on logic, not style.
If you’re still on ESLint + Prettier, Biome is worth evaluating. The migration is low-risk and the speed gain is immediate.
