How to build a TinyPNG alternative
How to build a TinyPNG alternative with a drop zone, local compression, and a bytes-saved number. Follow the prompts, check each step, and skip the CDN.
You can learn how to build a TinyPNG alternative as a drop zone that shrinks files in the tab. TinyPNG Pro is that product. The catalog line is “Drop image. Make image smaller. Fin.”
You pay $25–$39 a month. That is $300–$468 a year. The verdict is “Embarrassingly easy”. The first estimate is 60 minutes: drop images, compress them, download them.
I already wrote the teardown, Build a TinyPNG alternative. This guide is the walk. Prompts in order, then what to look at.
The audience is a designer compressing batches of web images. If you need a public compression API, or a WordPress plugin that rewrites every upload, keep paying. That week-long row is TinyPNG’s developer API: you POST bytes to api.tinify.com/shrink and pull the result. Build the drop zone if you are the one dropping files.
How to build a TinyPNG alternative without a CDN
The TinyPNG Pro page splits the work into three rows.
The first row is the one you want. A drop zone. A file picker. Each file validates and encodes in the tab. You see original bytes, result bytes, and the percentage saved. You download a new file. That is the 60-minute v1.
The second row is 3 hours. Quality controls, a side-by-side preview, a zip export. Do that after you have compressed a real folder.
The third row is a week. A public API, CDN delivery, CMS plugins, accounts. Skip it.
If you tell an agent to “clone TinyPNG”, it aims at the third row. Pin every prompt to the first one.
This one is different from Bitly or Linktree. Those need a URL and a bit of storage. The capability line here is Astro, Alpine, and the browser. No database. No login. The stack post says the same thing: if you pick Laravel, you still compress in the tab. You do not POST hero-home.jpg to a PHP worker and call that a v1.
Here is the shape of the data. Nothing else has to exist at 60 minutes.
const job = {
id: 'job-14',
filename: 'hero-home.jpg',
width: 2400,
height: 1350,
originalBytes: 1843200,
quality: 0.82,
outputType: 'image/webp',
state: 'queued',
resultBytes: null,
resultUrl: null,
}
You drop /Users/flavio/hero-home.jpg. The tab encodes a smaller file. You download hero-home.min.webp. Refreshing the page throws the work away, and that is fine. The originals never left the machine.
The card lists local ProcessingJob and ImageResult objects only. Tonight those can be one object. The official prompt still talks about tables and server actions. Reject that plan.
Pick the stack you already write
Every product prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The capability line for TinyPNG Pro is Astro, Alpine, and the browser.
This site is an Astro app on Cloudflare, so that is the path I default to. Use Next.js if that is your weekday language. The encode still runs in the tab.
Open Build your own TinyPNG Pro. The guardrail is the useful sentence: process images in the browser by default so originals never leave the device. The non-goal sits next to it. No accounts, cloud storage, billing, or an image CDN.
Pick one stack and stay on it.
Prompt 1. Scaffold Squooshbox and stop
Copy the AHA prompt from the product page. Paste it into your coding tool. Put this block above it.
Ship the 60-minute row only. Drop images, compress,
download.
Project name: Squooshbox.
There is no owner account. There is no database.
Jobs live in page state. Refreshing discards them.
Public URL is https://squish.flaviocopes.com.
Do not add quality sliders, a split preview, or zip
export yet.
Do not add accounts, cloud storage, billing, or a CDN.
Do not POST image bytes to a Worker, R2, or any server.
When the plan is done, stop and wait. Do not write
implementation code until I say go.
The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.
You want a page like this:
/for the workspace: drop zone, file picker, privacy line, batch list, running total, clear-all
You do not want /api/shrink. You do not want a jobs table. You do not want a sign-in screen.
If the plan includes D1, R2, a users table, or a public shrink endpoint, send it back. Paste the non-goal again.
When the plan is a static page plus Alpine state, say go. Let it scaffold the app and empty screens.
What to check after the scaffold
Start the app. You should see a drop zone, a picker, and a line that says the files stay local. There is no login.
Open the repo. There is no migration folder inventing processing_jobs. If the README is a placeholder, stop and make the agent write a real one before you add logic. The README has to say refresh discards the batch.
Prompt 2. Drop files and sniff them first
Agents trust the extension and the MIME type the OS invented. People rename a PDF to hero-home.jpg. The mini-prompt on the card is “The 80-megapixel photo that must not crash the tab”. Tonight, read the first bytes. Accept JPEG, PNG, and WebP. Reject everything else with a reason on that row. The rest of the batch keeps going.
Build intake for Squooshbox on
https://squish.flaviocopes.com.
The workspace accepts drag-and-drop and a file picker.
Multiple files in one drop. Each file becomes a row:
thumbnail, filename, dimensions, original bytes, state,
and a remove action.
Before any decode, sniff magic bytes. Accept JPEG, PNG,
and WebP. Reject everything else with a reason on that
row. Do not trust the extension or the OS MIME type.
After sniffing, read width and height. Refuse a file
over 80,000,000 pixels. Do not silently downscale.
Close the ImageBitmap if you opened one to count
pixels.
Seed these files in the README as the check:
- /Users/flavio/hero-home.jpg
- /Users/flavio/about-flavio.png
- a text file renamed to notes.jpg
A rejected row stays visible with its reason. Clear-all
removes every row and revokes object URLs.
Done when hero-home.jpg and about-flavio.png sit in the
list as queued, notes.jpg reads unsupported, and the
Network panel shows no image upload.
The rest of the 80-megapixel mini-prompt (feature-detecting AVIF, downscale-on-decode) can wait. Refuse, do not guess.
function sniffImage(bytes) {
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
return 'image/jpeg'
}
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return 'image/png'
}
if (
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
) {
return 'image/webp'
}
return null
}
sniffImage(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
//image/png
An 80-megapixel decode in RGBA is over 300 MB before you have encoded anything. I would refuse that file with a sentence on the row.
const MAX_PIXELS = 80000000
function tooManyPixels(width, height) {
return width * height > MAX_PIXELS
}
tooManyPixels(2400, 1350)
//false
tooManyPixels(12000, 8000)
//true
hero-home.jpg at 2400 by 1350 is 3,240,000 pixels. Fine. A 12,000 by 8,000 dump is 96,000,000. Refuse it.
What to check after intake works
Drop hero-home.jpg and about-flavio.png together. Two rows. Names and byte sizes stay after a re-render.
Rename a .txt file to notes.jpg and drop it. The row says unsupported. The other two stay queued.
Open DevTools. The Network panel has no POST of image bytes. If you see an upload to R2 “so the batch survives a refresh”, stop. Paste the non-goal again.
If the agent decodes first and sniffs later, send prompt 2 back. A 100-megapixel file will crash the tab before the error can render.
Prompt 3. Encode in the tab and show the saving
The official journeys mention a mixed batch that validates, compresses locally, then compares original and result sizes. Stay on that. The inspector and the zip belong to the 3-hour row.
Compress each queued job in the tab for Squooshbox.
Use canvas.toBlob tonight. Quality is 0.82. Default
output is image/webp when toBlob returns image/webp.
Probe WebP once at startup. If the probe returns a
different type, hide WebP and write image/jpeg.
Preserve the source width and height. Do not resize.
Do not upscale. Close the ImageBitmap after draw.
Each successful row shows:
- original bytes
- result bytes
- percentage saved
- output type
- a download action
One failed encode stays failed. The rest of the batch
continues. Never send image bytes to analytics or a
server.
Revoke the thumbnail URL and the result URL when the
row is removed or the batch is cleared.
Done when hero-home.jpg becomes a result I can
compare by bytes, about-flavio.png does the same, a
failed row does not block the other, and refresh
empties the list.
That is canvas JPEG or WebP, not TinyPNG’s encoder. Say that to yourself before you cancel anything. For a blog hero and a folder of screenshots, a quality of 0.82 is enough. You will not match their exact quantization. You will get a file you can ship.
async function compressFile(file, quality, outputType) {
const bitmap = await createImageBitmap(file)
const pixels = bitmap.width * bitmap.height
if (pixels > MAX_PIXELS) {
bitmap.close()
throw new Error(`Too many pixels: ${pixels}`)
}
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
canvas.getContext('2d').drawImage(bitmap, 0, 0)
bitmap.close()
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((result) => {
if (!result) {
reject(new Error('Could not encode the image'))
return
}
resolve(result)
}, outputType, quality)
})
return blob
}
Without original bytes, result bytes, and the percentage, you cannot tell whether the encode did anything.
function percentSaved(originalBytes, resultBytes) {
if (originalBytes <= 0) return 0
return Math.round((1 - resultBytes / originalBytes) * 100)
}
percentSaved(1843200, 1843200)
//0
percentSaved(1843200, 412000)
//78
If toBlob writes a file the same size as the source, the row shows 0 percent. That is the truth. Do not invent a fake saving.
The catalog mentions WebCodecs when the browser has them. Use canvas.toBlob tonight. Probe WebP once at startup and hide that output if toBlob returns a different type.
What to check after the first encode
Drop hero-home.jpg. The row leaves queued, hits processing, then done. Original bytes stay 1,843,200 if that is the file on disk. Result bytes are a different number. The percentage matches percentSaved.
Open the Network panel again. Still no image upload.
Remove the row. The thumbnail disappears. Drop the same file again. A new object URL, not a stale one.
If the agent writes a Worker that accepts multipart/form-data, delete that route. The guardrail is the whole product.
Prompt 4. Download a new name, never the original
The official rule is the one people skip. Never replace the original file in place. Write a new name. hero-home.jpg becomes hero-home.min.jpg, or hero-home.min.webp if the format changed.
Two source files named hero-home.jpg in one batch must not write the same output. The 3-hour zip has a suffix rule for that. Tonight you still need a name the browser can save.
Add downloads and a batch total for Squooshbox.
Never overwrite the source. Download a new filename:
- hero-home.jpg plus image/webp becomes
hero-home.min.webp
- about-flavio.png plus image/png becomes
about-flavio.min.png
Two sources that would share an output name get a
numeric suffix. The second hero-home.jpg in the same
batch downloads as hero-home-2.min.webp.
The workspace shows a running total of bytes saved
across successful rows. Failed and rejected rows add
zero. Clear-all resets the total.
Download is a local object URL. Do not upload to
create the file.
Do not add zip export yet. Do not add a quality
slider yet.
Done when I can download hero-home.min.webp, a
second hero-home.jpg in the same batch gets a
different name, and the total equals the sum of
original minus result on the successful rows.
function outputName(filename, outputType, usedNames) {
const dot = filename.lastIndexOf('.')
const stem = dot === -1 ? filename : filename.slice(0, dot)
const ext = outputType === 'image/webp'
? 'webp'
: outputType === 'image/png'
? 'png'
: 'jpg'
let name = `${stem}.min.${ext}`
let n = 2
while (usedNames.has(name)) {
name = `${stem}-${n}.min.${ext}`
n += 1
}
usedNames.add(name)
return name
}
const used = new Set()
outputName('hero-home.jpg', 'image/webp', used)
//hero-home.min.webp
outputName('hero-home.jpg', 'image/webp', used)
//hero-home-2.min.webp
Preserve aspect ratio. Tonight you do not resize at all. Same pixels, fewer bytes.
The running total is addition, not a second encode.
function batchSaved(jobs) {
let saved = 0
for (const job of jobs) {
if (job.state !== 'done') continue
saved += job.originalBytes - job.resultBytes
}
return saved
}
batchSaved([
{ state: 'done', originalBytes: 1843200, resultBytes: 412000 },
{ state: 'failed', originalBytes: 88000, resultBytes: null },
])
//1431200
Failed rows add nothing. A rejected notes.jpg does not appear in the total.
What to check after downloads work
Download hero-home.min.webp. Open it. Same 2400 by 1350. The folder still has hero-home.jpg.
Drop a second hero-home.jpg into the same batch. The second download is hero-home-2.min.webp.
Clear the list. The total returns to 0. Object URLs from the old batch no longer resolve.
If the download is named hero-home.jpg, the agent skipped the suffix. Send prompt 4 back before you replace the only copy of a hero image.
Failures the agent ships anyway
An upload “for later”
If the batch survives a refresh, the originals left the device. The completion screen already reminds you that refreshing discards local work. Keep it that way.
Trusting the extension
notes.jpg can be a PDF. Sniff the bytes. The decoder never sees a renamed text file.
Decoding before the budget
Count pixels, then encode. An 80-megapixel photo decoded to RGBA is over 300 MB. The row should refuse it. The rest of the batch should still work.
Replacing the original
A download named hero-home.jpg is a data-loss bug. The new file gets .min. in the name.
Matching TinyPNG’s encoder
canvas.toBlob is not their quantization. Google’s Squoosh is a public, open-source compressor that already lives in the tab. Use it as a UI reference if you want. Do not paste their encoder stack into a 60-minute v1.
A public /shrink route
That is the week-long row. A public /shrink endpoint hosts other people’s photos.
A framed screenshot for a launch tweet is a different job. That is Shots.so, $10–$20 a month, about 2 hours. The Shots.so walkthrough stays in the browser too. Crop and annotate is Photoshop, $9.99–$22.99 a month, about 12 hours.
Deploy the static page
The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.
Squooshbox wants Astro, Alpine, and the browser. Put it on a subdomain you already own, squish.flaviocopes.com, or a path next to a site you already deploy. There is no database to back up.
This site ships to Cloudflare. A static Astro page on Pages is enough. Attach the custom domain. The public URL is the product, so squish.flaviocopes.com has to resolve on the public internet, not only on localhost.
Ship the empty workspace. Drop hero-home.jpg from a phone on cellular, not the same Wi-Fi as your laptop. Confirm the Network panel is still quiet. Confirm the download lands as hero-home.min.webp.
Use it for a week as the real tool, not as a demo. If you miss a zip or a split preview, those are the 3-hour row and the other mini-prompts on the card. Add them after you compress a real folder.
Then cancel TinyPNG Pro.
What to skip on purpose
Leave out accounts, a login wall, cloud storage, billing, and anything that looks like an image CDN.
Quality controls and a zip are the honest next feature, not a v1 requirement. The preview mini-prompt is “A before-and-after slider that tells the truth”. Both halves share one zoom and one pan. The zip mini-prompt is “Build the ZIP yourself, byte by byte”. Store mode, because the images are already compressed. The TinyPNG walkthrough covers that 3-hour row.
A page of links is a different job. That is the Linktree guide. $9–$24 a month, about 30 minutes.
A one-page site for the compressor marketing blurb is Carrd, $2–$4 a month, about 2 hours. The Carrd walkthrough is that post.
A status board for the day the page is down is the status page guide. You will not need it for a static tab.
More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.
Start now
Go to Build your own TinyPNG Pro. Copy the AHA prompt, or the stack you already write.
Tell the agent the first estimate only: drop images, compress, download. Sixty minutes.
Publish squish.flaviocopes.com. Drop one real hero image. Put the smaller file on a page tonight.