Four years of optimizing React and Next.js projects taught me one thing: unoptimized images are everywhere, and nobody wants to fix them.
Every project has the same pattern. Heavy PNG and JPG files are sitting inside /public, there is no consistent image pipeline, and some of those files have no business being that large in a production codebase.
This is especially common in small and mid-sized projects. There is no CDN transformation layer or dedicated asset pipeline. Images get added while the product is moving quickly, and the cleanup becomes a task for “later.”
Later, of course, never comes.
Then, at 1am, while refactoring an extremely vibe-coded Next.js project, I found myself doing the cleanup manually again.
Find an image. Upload it to an online compressor. Hit the free limit. Open another tool. Convert a few more. Download everything. Replace the original files. Hunt through the codebase for every import and src path. Hope I did not miss one.
And I finally thought: I am a developer. Why am I doing this by hand?
So I built pixcrush.
npx pixcrush .
One command to convert the images, compress them, and update their matching code references automatically.
“But doesn’t Next.js already optimize images?”
Yes, and if your application uses next/image consistently, you should absolutely take advantage of it.
The Next.js <Image> component can resize images for different devices, lazy-load them, and serve modern formats such as WebP. Files inside /public can be referenced from the root URL, while statically imported images also give Next.js access to their intrinsic dimensions. The official Next.js image documentation explains these runtime optimizations in detail.
But that solves a different layer of the problem.
I wanted to clean up the source assets themselves:
- Replace heavy PNG and JPG files with smaller WebP files when conversion is worthwhile.
- Update existing imports and string-based image paths across the repository.
- Identify images that are no longer referenced.
- Make the migration reviewable before touching any files.
- Keep the process useful for projects that do not consistently use
next/image.
This was not about replacing the Next.js image pipeline. It was about making a tedious codebase migration safe and repeatable.
Why a simple format conversion becomes a code migration
Consider a small project with images referenced in a few different ways:
import hero from './assets/hero.png';
export function HomePage() {
return (
<main>
<Image src={hero} alt="" />
<img src="/images/team.jpg" alt="Our team" />
</main>
);
}
Converting hero.png and team.jpg is only half of the job. The code now points to files that no longer represent the desired output.
After the migration, the references need to become:
import hero from './assets/hero.webp';
export function HomePage() {
return (
<main>
<Image src={hero} alt="" />
<img src="/images/team.webp" alt="Our team" />
</main>
);
}
Now multiply that across .js, .jsx, .ts, .tsx, .html, and .json files, plus nested apps inside a Turborepo.
That is no longer an image-conversion task. It is a codemod with image processing attached.
The workflow I wanted
I wanted the tool to perform five clear phases:
- Scan the repository for supported images and source files.
- Determine which images are actually referenced.
- Convert used PNG and JPG files to WebP.
- Update only the references connected to successful conversions.
- Report what changed, what was skipped, and what needs human review.
The default command is intentionally small:
npx pixcrush .
For a first pass on an unfamiliar project, I prefer starting with a dry run:
npx pixcrush . --dry-run
This previews the migration without writing files.
Step 1: Find the images and their references
pixcrush scans for PNG, JPG, and JPEG files while ignoring directories such as node_modules, .next, dist, build, and .git.
It separately collects the source files that may contain references:
.js .jsx .ts .tsx .html .htm .json
Finding image files is easy. Determining whether they are used is the interesting part.
For JavaScript and TypeScript, pixcrush parses the source with Babel using the JSX and TypeScript plugins. Babel’s parser produces an abstract syntax tree, which makes it possible to inspect actual string literals instead of blindly replacing every .png substring. Babel documents its JSX and TypeScript parsing support in the @babel/parser reference.
The tracker resolves several common patterns:
import logo from './logo.png';
<Image src="/images/hero.jpg" alt="" />
const imagePath = '@/assets/product.png';
It also handles image-bearing values in HTML and JSON, including srcset-style strings and query parameters.
For absolute paths such as /images/hero.jpg, pixcrush reconciles the reference with matching public directories. This matters in monorepos, where the real file may be located at something like:
apps/web/public/images/hero.jpg
Images without a resolved reference are reported as unused instead of being converted automatically.
Step 2: Convert in memory, and keep the original when WebP is larger
The actual image conversion uses Sharp, which supports WebP output and exposes a configurable quality setting.
The important part is not just calling .webp(). pixcrush creates the WebP data in memory first:
const webpBuffer = await sharp(imagePath)
.webp({ quality })
.toBuffer();
Sharp’s toBuffer() documentation confirms that WebP output can be generated directly as a buffer. That lets pixcrush compare the proposed output with the original before writing anything.
if (webpBuffer.length >= originalSize) {
// Skip this conversion.
}
WebP is often smaller, but “modern format” does not automatically mean “smaller file” for every source image. If the generated WebP would be the same size or larger, pixcrush skips it.
More importantly, skipped images are not added to the rewrite map. Their code references remain unchanged.
The default WebP quality is 80, and it can be adjusted:
npx pixcrush . --quality 90
Step 3: Rewrite code without reformatting the entire file
The riskiest part of the migration is not creating a .webp file. It is changing the code safely.
A global find-and-replace is tempting:
.png → .webp
It is also far too broad. It can modify unrelated text, references to images that were skipped, or paths that do not resolve to a file pixcrush converted.
Instead, pixcrush builds a map containing only successful conversions:
/absolute/path/hero.png → /absolute/path/hero.webp
It parses each JavaScript or TypeScript source file, visits its string literals, resolves candidate image paths, and creates targeted text replacements only for matches in that conversion map.
The replacements are applied from the end of the file toward the beginning. This prevents an earlier edit from shifting the offsets required by a later edit.
There is another subtle benefit: pixcrush does not regenerate the entire source file from the AST. It replaces only the extension inside the matched string. Existing formatting, comments, quote styles, and surrounding code remain untouched.
Query strings are preserved too:
/images/hero.png?v=2
becomes:
/images/hero.webp?v=2
The safety rules matter more than the happy path
Code-modifying tools should be conservative. I would rather have pixcrush warn about one path than confidently break a project.
These are the guardrails I added:
Dry runs do not write files
npx pixcrush . --dry-run
This runs the analysis, conversion calculation, and reference-matching flow without writing the WebP files or modified source.
Dynamic paths are reported, not guessed
pixcrush can understand a static value:
<img src="/products/shoe.png" alt="" />
It cannot safely resolve every runtime value:
<img src={`/products/${product.slug}.png`} alt="" />
Dynamic template paths are warned about so they can be reviewed manually.
Parse failures block destructive cleanup
If a source file cannot be parsed, pixcrush records the failure. When --delete-originals is enabled, original and unused images are deleted only if both the analysis and rewrite phases completed without parse failures.
Framework metadata images are excluded
Files such as favicons, Apple touch icons, Open Graph images, Twitter images, and common PWA manifest icons may be discovered through framework conventions rather than ordinary source references.
pixcrush excludes these patterns from the migration instead of incorrectly treating them as orphans.
External and inline images are left alone
HTTP URLs and data: values are not local assets, so the tracker ignores them.
Running the full migration
My preferred workflow is:
# 1. Preview the migration
npx pixcrush . --dry-run
# 2. Review the reported conversions, warnings, and unused files
# 3. Run the migration
npx pixcrush .
# 4. Run the application and its tests
# 5. Remove converted originals only when ready
npx pixcrush . --delete-originals
The final summary includes:
- Images converted
- Space saved
- Source files updated
- Unused images detected
- Dynamic references or parsing issues requiring review
I still recommend running this on a clean Git branch. A dry run reduces surprises; Git gives you the final, exact review surface.
What pixcrush does not handle yet
The current limitations are intentional and worth stating clearly:
- CSS files are not parsed, so references inside
url(...)are not rewritten. An image referenced only from CSS may also appear in the unused-image report; review that list carefully before using--delete-originals. - Dynamic paths are detected but not automatically converted.
- The image inputs are currently PNG, JPG, and JPEG.
- Framework-convention metadata images are excluded for safety.
If a project relies heavily on CSS backgrounds or dynamically generated filenames, expect some manual work after the automated pass.
“Automated” should not mean “pretend every edge case is safe.”
What changed for me
The biggest improvement is not that Sharp can produce a WebP file. The biggest improvement is that the full cleanup is now one reviewable operation.
The old workflow looked like this:
find → upload → hit a limit → try another tool → download
→ replace files → search the codebase → update paths → repeat
The new workflow is:
npx pixcrush . --dry-run
npx pixcrush .
That is the kind of automation I like: not a new platform, dashboard, subscription, or upload queue. Just a local tool that removes a chore and then gets out of the way.
Try it
pixcrush is open source and runs locally:
npx pixcrush .
If you try it on a real React, Next.js, or Turborepo project, I would especially love feedback on path-resolution edge cases and file types worth supporting next.
Top comments (1)
Great example of turning a personal pain point into a useful developer tool. A CLI approach makes a lot of sense here — better privacy, automation, batch processing, and integration into existing workflows. It’s often these small friction points in daily development that lead to the most practical open-source projects. Nice work!