As a senior frontend engineer, I've seen my fair share of "console log graveyards". You know the ones production websites where you open the DevTools and see a messy trail of Object { data: "test" } and HERE 1111 leaking everywhere.
It's messy, it can leak metadata, and it looks amateur. But we still need logs to build things quickly! In 2026, we don't just "turn off" logs; we manage them like pros. Here is the exact stack I use to keep my console clean and my sanity intact.
1. The Architectural Solution: The "Proxy" Logger
Instead of calling console.log directly, we create a small wrapper. Think of it as a "smart filter." By using a JavaScript proxy, we can mimic the entire console API without writing every method manually.
The TypeScript Implementation
Using typeof console ensures that your editor gives you full IntelliSense/autocomplete for every method (.log, .table, .group, etc.).
// utils/logger.ts
const isDev = process.env.NODE_ENV === 'development';
/**
* The 'debug' proxy mirrors the console API perfectly.
* In development: works normally.
* In production: returns an empty function (no-op).
*/
export const debug = new Proxy(console, {
get(target, prop: keyof typeof console) {
if (isDev) {
const value = target[prop];
if (typeof value === 'function') {
// Bind 'this' to target (console) so it doesn't lose context
return value.bind(target);
}
return value;
}
// In production, return an empty function that does nothing
return () => {};
}
}) as typeof console;
2. Keeping ESLint and Biome Happy
Now that we have our debug tool, we need to ensure the team uses it instead of raw console.log.
For ESLint: Set your global rule to error on raw console calls.
// eslint.config.js
export default [{ rules: { "no-console": "error" } }];
For Biome: Use an override in biome.json so the linter ignores the noConsole rule only inside your logger utility.
{
"linter": { "rules": { "suspicious": { "noConsole": "error" } } },
"overrides": [
{
"include": ["src/utils/logger.ts"],
"linter": { "rules": { "suspicious": { "noConsole": "off" } } }
}
]
}
3. Real-World Usage: Seeing it in Action
Once set up, your daily workflow remains unchanged. You get all the power of the console without the risk of shipping it to users.
// components/UserDashboard.tsx
import { debug } from '../utils/logger';
export const UserDashboard = () => {
const loadData = async () => {
debug.group('Fetching Dashboard'); // Grouping keeps your dev console tidy
try {
const data = await fetch('/api/users').then(res => res.json());
debug.log('Users received:', data.length);
debug.table(data); // Beautiful table view in development!
} catch (err) {
debug.error('Failed to load:', err);
} finally {
debug.groupEnd();
}
};
return <button onClick={loadData}>Load Data</button>;
};
4. The "Senior" Finishing Touch: Build-Time Stripping
Even though our proxy is silent in production, the code for those logs is still in your bundle. To be a true professional, you should physically remove them during the build.
For Next.js Projects
Use the built-in compiler in next.config.mjs:
const nextConfig = {
compiler: {
// Shreds all console.* calls during the production build
removeConsole: process.env.NODE_ENV === 'production',
},
};
export default nextConfig;
For Vite Projects
Tell Vite to "drop" these specific calls during the minification phase:
// vite.config.ts
export default defineConfig({
esbuild: {
drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [],
},
});
Why This Wins
By combining a proxy logger, strict linting, and compiler stripping, you've achieved the "golden path" of logging:
-
Zero Visual Noise: No more
// eslint-disablecomments. -
Full Power: You still get
.table(),.group(), and.warn()with full autocomplete. - Maximum Security: No sensitive strings leak into your production source code.
- Optimal Performance: Your production bundle is smaller because the logs are physically deleted.
It's a small architectural change that makes a massive difference in the long-term health of a project. Happy coding!
Top comments (0)