How We Maintain Code Quality Across Multiple Client Projects
At Threshline, we typically run three to four client projects in parallel with a team of four engineers. That means any one of us might context-switch between a SvelteKit dashboard, a Flutter mobile app, and an Astro marketing site in a single week. Without deliberate systems, code quality drifts. Style inconsistencies creep in. Technical debt accumulates quietly until it becomes a problem.
This post covers the systems we use to keep quality consistent across projects. None of this is revolutionary. It is boring, repeatable infrastructure that works.
The Shared Configuration
Every new project at Threshline starts from a base configuration that includes linting, formatting, TypeScript settings, and Git hooks. We maintain these as a set of shareable config packages that we pull into each project.
ESLint Configuration
We use a shared ESLint flat config as our starting point:
// eslint.config.ts — our base shared config
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import sveltePlugin from 'eslint-plugin-svelte';
import svelteParser from 'svelte-eslint-parser';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.strict,
{
rules: {
// We enforce these across all projects
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
eqeqeq: ['error', 'always'],
},
},
// Svelte-specific config — included only for SvelteKit projects
{
files: ['**/*.svelte'],
plugins: { svelte: sveltePlugin },
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
rules: {
...sveltePlugin.configs.recommended.rules,
'svelte/no-at-html-tags': 'warn',
},
},
{
ignores: [
'node_modules/**',
'.svelte-kit/**',
'dist/**',
'build/**',
],
}
);
The key philosophy: strict TypeScript, no unused variables, no any types without justification, no loose equality. These rules catch real bugs. We keep the rule set intentionally small — every rule must prevent an actual problem we have seen in production.
Prettier Configuration
Formatting debates are a waste of engineering time. We standardize on Prettier with a shared configuration and never discuss formatting again:
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-astro"],
"overrides": [
{
"files": "*.svelte",
"options": { "parser": "svelte" }
},
{
"files": "*.astro",
"options": { "parser": "astro" }
}
]
}
Every project uses the same formatting. When an engineer switches from one project to another, the code looks the same. No mental overhead from different indentation styles or quote preferences.
TypeScript Configuration
We use strict TypeScript in every project. The base tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ESNext"
}
}
The noUncheckedIndexedAccess rule is one we feel strongly about. It forces you to handle the case where an array or object access might return undefined. This catches a class of runtime errors that strict mode alone does not.
// Without noUncheckedIndexedAccess
const items = ['a', 'b', 'c'];
const first = items[0]; // type: string — but what if items is empty?
// With noUncheckedIndexedAccess
const first = items[0]; // type: string | undefined — forces you to handle it
if (first) {
console.log(first.toUpperCase()); // safe
}

Git Hooks and Pre-Commit Checks
We use Husky and lint-staged to run checks before every commit. This catches issues before they reach the pull request:
// package.json
{
"lint-staged": {
"*.{ts,tsx,svelte,astro}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,css}": [
"prettier --write"
]
}
}
The pre-commit hook runs ESLint and Prettier only on staged files, so it is fast — usually under two seconds. We also run a type check (tsc --noEmit) on the full project, but only in the pre-push hook since it takes longer.
# .husky/pre-commit
npx lint-staged
# .husky/pre-push
npx tsc --noEmit
This two-tier approach means you can commit quickly while still catching type errors before they leave your machine.
The Pull Request Process
Every change goes through a pull request with at least one review. On a four-person team, this is straightforward — you tag whoever is not actively heads-down on their own feature.
What We Look For in Reviews
We have a mental checklist that we apply to every PR. It is not a formal document — it is a shared understanding built from years of working together:
Correctness first. Does the code actually do what the PR description says? We read the diff carefully, trace the data flow, and check edge cases. This is the most important part of the review and the one most people rush.
Error handling. Is every external call wrapped in appropriate error handling? Are errors logged with enough context to debug them later? We have a rule: if a function calls an external service (database, API, file system), it must handle the failure case explicitly.
// What we reject in review
async function getUser(id: string) {
const user = await db.users.findById(id);
return user; // What if user is null? What if db throws?
}
// What we expect
async function getUser(id: string): Promise<User | null> {
try {
const user = await db.users.findById(id);
if (!user) {
return null;
}
return user;
} catch (error) {
console.error(`Failed to fetch user ${id}:`, error);
throw new DatabaseError(`User lookup failed for ${id}`, { cause: error });
}
}
Naming. Variable and function names should be descriptive enough that you do not need the comment. We push back on abbreviated names, generic names like data or result, and names that do not match what the code does.
Simplicity. Can this be done with less code? Is there a built-in API or existing utility that does the same thing? We frequently catch cases where someone wrote a helper function that already exists in the codebase or in a standard library.
Security. Is user input validated? Are SQL queries parameterized? Are secrets kept out of the codebase? We treat security issues in reviews as blockers, not suggestions.
PR Size Limits
We have an informal rule: if a PR is over 400 lines of diff, it is too big. Large PRs get rubber-stamped because they are exhausting to review. We would rather review three focused PRs of 150 lines each.
When a feature is large, we break it into layers:
- Database schema changes and migrations
- Backend API endpoints and business logic
- Frontend UI and integration
Each layer gets its own PR, its own review, and its own merge. This also means we can catch issues early — a schema design problem found in PR 1 is much cheaper to fix than discovering it in PR 3 after the frontend is built on top of it.

CI Pipeline
Every project has a CI pipeline that runs on every push to a PR branch. The pipeline includes:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main, develop]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npx tsc --noEmit
- run: npx eslint .
- run: npx prettier --check .
- run: npm test
build:
runs-on: ubuntu-latest
needs: quality
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- run: npm ci
- run: npm run build
The pipeline is sequential by design: type checking, linting, and formatting run first (fast), followed by tests, followed by a full build. If types are wrong, we do not waste time running tests.
We do not merge PRs with failing CI. No exceptions. The “it works on my machine” excuse is why CI exists.
The Coding Standards Document
We maintain a living document that covers the conventions we follow across projects. It is not a 50-page style guide. It is a short document — maybe 2,000 words — that covers the decisions we have already made so we do not re-debate them.
Here are some of the things it covers:
File naming conventions. Components are PascalCase. Utilities are camelCase. Routes follow the framework convention (kebab-case for Astro, directory-based for SvelteKit).
Import ordering. External packages first, then internal modules, then relative imports. Each group separated by a blank line. We enforce this with ESLint’s import rules.
// External packages
import { json } from '@sveltejs/kit';
import { z } from 'zod';
// Internal modules
import { db } from '$lib/server/database';
import { validateSession } from '$lib/server/auth';
// Relative imports
import type { PageServerLoad } from './$types';
Error handling patterns. We use custom error classes with error codes. Every API endpoint returns errors in a consistent shape.
// Consistent error response shape across all projects
interface ApiError {
code: string;
message: string;
details?: Record<string, unknown>;
}
// Example: validation error
function createValidationError(issues: z.ZodIssue[]): ApiError {
return {
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
details: {
issues: issues.map((i) => ({
path: i.path.join('.'),
message: i.message,
})),
},
};
}
Database query patterns. We use parameterized queries exclusively. Complex queries get their own functions in a queries/ directory. We never build SQL strings with concatenation.
Environment variables. Always validated at startup using Zod. If a required env var is missing, the app crashes immediately with a clear error message instead of failing later with a confusing one.
// src/lib/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
APP_URL: z.string().url(),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
});
export const env = envSchema.parse(process.env);
Project Templates
When we start a new project, we do not start from scratch. We have templates for the most common project types:
- Astro marketing site — the base for sites like this one, with Content Collections, vanilla CSS, and our standard tooling
- SvelteKit application — full-stack setup with Supabase, Tailwind, authentication, and our error handling patterns
- Flutter mobile app — project structure, state management setup, and CI for building APKs and IPAs
These templates include all the configuration we have described — ESLint, Prettier, TypeScript, Git hooks, and CI. A new project goes from zero to “ready for feature development” in under 30 minutes.
We keep the templates updated. When we learn something on a client project — a better ESLint rule, a CI optimization, a useful utility function — we back-port it to the relevant template. This way, every new project benefits from everything we have learned on previous ones.

Dependency Management
We review dependencies carefully. Every new package added to a project needs to justify its existence. Our questions are:
- Can we do this with what we already have? We have seen projects with five different date libraries. One is enough.
- Is this package maintained? We check the last commit date, open issues, and download trends. Abandoned packages are a liability.
- What is the bundle impact? We check bundlephobia before adding anything to a frontend project.
- Do we understand what it does? If a package is a black box, we are wary. Dependencies should be tools we understand, not magic we depend on.
We run npm audit in CI and address vulnerabilities promptly. We use Dependabot for automated update PRs, but we review them manually rather than auto-merging.
The Boring Truth
None of what we have described is exciting. There is no clever trick or proprietary tool. It is ESLint, Prettier, TypeScript, Git hooks, code review, and CI. The same tools every team has access to.
The difference is consistency. We actually use these tools on every project. We do not skip the review because we are in a rush. We do not merge with failing CI because the deadline is tight. We do not add dependencies without checking them.
Code quality is not a feature you ship once. It is a practice you maintain. On a small team running multiple projects, it is the difference between sustainable output and gradually increasing chaos.
If you are building a product and want a team that takes this stuff seriously, reach out at [email protected].