A Progressive Web App for compressing images via the TinyPNG API. Fast, beautiful, installable — no account required beyond your own API key.
- Drag-and-drop (or file-picker) upload of PNG, JPEG, WebP, and AVIF images
- Sends each image to TinyPNG for compression, shows live progress and savings percentage
- Download individual compressed files, or zip all results with Download all
- Copies the shareable TinyPNG CDN link to clipboard
- Tracks your monthly compression quota (500/month on the free plan)
- Installable as a PWA (works offline for the shell; compression requires network)
- Dark / light mode with system preference detection
TinifyPWA uses a BYOK model. You supply your own TinyPNG API key:
- Open the app and click Add key in the nav bar
- Paste your API key — it is saved in
localStorageand never leaves your browser except as an HTTP header to your own Nuxt server proxy - The proxy forwards requests to TinyPNG; the key is never logged or persisted server-side
| Concern | Package |
|---|---|
| Framework | Nuxt 4 (compatibilityVersion: 4), ssr: false |
| Styling | CSS custom properties + scoped component CSS |
| Icons | lucide-vue-next |
| Animation | GSAP via @hypernym/nuxt-gsap |
| Zipping | fflate (browser-side, level-0 store for pre-compressed images) |
| PWA | @vite-pwa/nuxt |
| Dark/light mode | @nuxtjs/color-mode |
| Utilities | @vueuse/nuxt |
| TinyPNG SDK | tinify (server-side, usage endpoint only) |
npm install
cp .env.example .env # not required — no server-side key needed
npm run devOpen http://localhost:3000.
TinifyPWA/
├── app/
│ ├── assets/css/main.css # design tokens, global component classes
│ ├── components/
│ │ ├── AppNav.vue # nav bar with key button and theme toggle
│ │ ├── Base/BaseModal.vue # reusable modal (ESC closes, click-outside does not)
│ │ ├── CookieBanner.vue
│ │ ├── Upload/
│ │ │ ├── UploadZone.vue # drag-and-drop + file picker
│ │ │ └── UploadStats.vue # quota bar, formats, max size
│ │ ├── Process/
│ │ │ ├── ProcessPanel.vue # results list header with Download all / Clear all
│ │ │ └── ImageCard.vue # per-image card: progress, savings, download, share
│ │ └── Modals/
│ │ ├── ApiKeyModal.vue
│ │ ├── AboutModal.vue
│ │ ├── ImprintModal.vue
│ │ └── PrivacyModal.vue
│ ├── composables/
│ │ ├── useTinify.ts # image state, sequential queue, compress, downloadAll
│ │ └── useApiKey.ts # localStorage BYOK
│ ├── layouts/default.vue
│ └── pages/index.vue # two-panel desktop / stacked mobile layout
├── server/api/
│ ├── compress.post.ts # proxy: base64 JSON → TinyPNG /shrink
│ ├── download.get.ts # proxy: authenticated download stream
│ └── usage.get.ts # proxy: validate key + read Compression-Count header
├── server/utils/tinify.ts # Basic auth helper
├── public/icons/ # PWA icons (192, 512, 512 maskable)
├── scripts/generate-icons.mjs # icon generation via sharp
├── nuxt.config.ts
└── package.json
npm run dev # development server with HMR
npm run build # production build
npm run generate # static site generation
npm run preview # preview production build locally
node scripts/generate-icons.mjs # regenerate PWA icons- Images are encoded as base64 JSON for the browser → Nitro leg of the request (avoids binary streaming issues in the dev server). The Nitro → TinyPNG leg sends raw
application/octet-stream. - Compression runs through a sequential queue to avoid concurrent body-streaming issues.
- The
fflatezip useslevel: 0(store-only) because TinyPNG output is already compressed; re-compressing wastes CPU with no size benefit. compressionCountis in-memory client state, refreshed from TinyPNG on page load and after each compression.