A media downloader web app that lets users download videos and audio from popular social platforms by pasting a URL. No login. No accounts. No ads.
Built with a React frontend (Vite, TypeScript, Tailwind CSS v4) and a Hono backend (Node.js), deployable independently β frontend on Vercel, backend on Fly.io.
- Paste a URL from any supported platform and extract available formats instantly
- Choose from all available formats: combined video+audio, video-only, audio-only, by quality tier
- Multi-URL queue β add multiple links and resolve them independently
- Dark mode and light mode with persistent preference
- No login, no tracking, no ads
- Streaming downloads β files never touch our storage
- Built-in rate limiting to protect the backend from abuse
- Fully typed, tested, and production-ready
YouTube, Instagram, Facebook, TikTok, X (Twitter), Snapchat, Reddit, Vimeo, Twitch (clips), Pinterest, Dailymotion, SoundCloud, LinkedIn, Tumblr, and any other platform supported by yt-dlp.
| Layer | Technology |
|---|---|
| Frontend | React 19, Vite, TypeScript, Tailwind CSS v4, Framer Motion |
| UI Primitives | Radix UI (Select, Dialog, Toast, AlertDialog), custom-styled |
| State | TanStack Query v5, React Router v7 |
| Backend | Hono v4, Node.js adapter, TypeScript |
| Media | yt-dlp, ffmpeg (static binaries, resolved at startup) |
| Logging | Pino (JSON in production, pretty in development) |
| Validation | Zod v4 (shared schemas between request/response) |
| Testing | Vitest (frontend: 22 tests, backend: 34 tests) |
| Deploy | Vercel (frontend), Fly.io (backend) |
veeyds/
βββ client/ # React frontend (Vite)
β βββ src/
β β βββ app.css # Tailwind v4 + design tokens + base styles
β β βββ App.tsx
β β βββ main.tsx
β β βββ components/
β β β βββ ui/ # Button, Input, Badge, Skeleton, Dialog, Dropdown, Toast
β β β βββ layout/ # Header, Footer, ThemeToggle
β β β βββ home/ # HeroSection, PlatformGrid, HowItWorks, FeatureStrip
β β β βββ download/ # UrlInput, FormatSelector, MediaPreview, QueueItem, DownloadQueue
β β βββ hooks/ # useDownload, useTheme, useMediaQuery, useToast
β β βββ pages/ # HomePage, DownloadPage
β β βββ services/ # api.ts β typed fetch wrappers
β β βββ types/ # media.ts, api.ts
β β βββ utils/ # cn.ts, format.ts, validators.ts
β βββ vercel.json # SPA rewrites, asset caching, security headers
β βββ .env.example
β
βββ server/ # Hono backend (Node.js)
β βββ src/
β β βββ index.ts # App entry, middleware, Swagger UI, error handling
β β βββ routes/ # media.ts
β β βββ controllers/ # media.controller.ts
β β βββ services/ # extractor.service.ts, stream.service.ts
β β βββ middleware/ # cors.ts, rateLimit.ts, validate.ts
β β βββ utils/ # ytdlp.ts, sanitize.ts, process.ts, format.ts
β β βββ types/ # media.ts β Zod schemas + inferred types
β βββ tests/
β β βββ unit/ # sanitize, format, rateLimit, validate
β β βββ integration/ # api.test.ts
β βββ scripts/
β β βββ install-bins.sh # Downloads yt-dlp and ffmpeg static binaries
β βββ Dockerfile # Multi-stage Docker image for Fly.io
β βββ fly.toml # Fly.io deployment configuration
β βββ .env.example
β
βββ render.yaml # Render infrastructure-as-code (backend) (legacy)
βββ .gitignore
βββ .node-version # Pins Node 22
- Node.js 22+
- pnpm 10+
yt-dlpandffmpegβ installed automatically by the setup script on Linux, or via your system package manager on macOS
git clone https://github.com/habibthadev/veeyds.git
cd veeydscd server
pnpm install
# Downloads yt-dlp and ffmpeg to server/bin/ (Linux only β use brew on macOS)
pnpm run setup
# Start with hot-reload
pnpm devThe server starts at http://localhost:3001.
On macOS, install dependencies manually before pnpm dev:
brew install yt-dlp ffmpegcd client
pnpm install
pnpm devThe frontend starts at http://localhost:5173. It proxies all /api requests to localhost:3001 automatically.
| Variable | Default | Description |
|---|---|---|
PORT |
3001 |
Port the server listens on |
NODE_ENV |
development |
Set to production for JSON logs, strict CORS |
ALLOWED_ORIGINS |
(allow all) | Comma-separated allowed origins, e.g. https://veeyds.vercel.app |
API_BASE_URL |
http://localhost:3001 |
Used in the OpenAPI spec servers field |
Copy the example and fill in values:
cp server/.env.example server/.env| Variable | Default | Description |
|---|---|---|
VITE_API_URL |
(empty) | Backend base URL (no trailing slash, no /api). Leave empty in dev β Vite proxy handles it. Set to the Render URL in production. |
cp client/.env.example client/.envInteractive docs are available at http://localhost:3001/api/docs (Swagger UI) when the server is running.
Extracts metadata and available formats for a given URL.
Rate limit: 30 requests per IP per 15 minutes.
Request body:
{ "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }Response:
{
"title": "Rick Astley - Never Gonna Give You Up",
"thumbnail": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
"duration": 213,
"platform": "Youtube",
"originalUrl": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"formats": [
{
"id": "137+140",
"ext": "mp4",
"resolution": "1920x1080",
"filesize": 52428800,
"hasAudio": true,
"hasVideo": true,
"label": "1920x1080 Β· mp4 Β· 50 MB"
}
]
}Error responses:
| HTTP | Code | Cause |
|---|---|---|
| 400 | INVALID_INPUT |
Missing or malformed URL |
| 422 | UNSUPPORTED_URL |
Platform not supported by yt-dlp |
| 422 | EXTRACTION_FAILED |
yt-dlp failed to extract info |
| 429 | RATE_LIMITED |
Too many requests β see Retry-After header |
| 500 | INTERNAL |
Unexpected server error |
Streams a media file directly to the client. The browser triggers a native file download.
Rate limit: 10 requests per IP per 15 minutes.
Query parameters:
| Parameter | Required | Description |
|---|---|---|
url |
Yes | The original media URL |
formatId |
Yes | Format ID from the /info response |
Download strategy:
- Formats that are already muxed (audio+video in one stream): piped directly from yt-dlp stdout to the response β zero disk usage.
- Formats that require muxing (separate video + audio streams merged by ffmpeg): written to a unique temp file, streamed to client, then deleted.
Response headers:
Content-Disposition: attachment; filename="Video_Title.mp4"
Content-Type: video/mp4
Content-Length: 52428800 (only for muxed downloads)
Returns server health status. Used by Fly.io for health checks.
{ "status": "ok" }All error responses use a consistent structure:
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please try again later."
}
}cd server
pnpm test34 tests: 4 unit test suites (sanitize, format, rateLimit, validate) and 1 integration suite.
cd client
pnpm test22 tests: utility functions (cn, format), component rendering (Badge, Input), and hook behaviour (useDownload).
The server/fly.toml at configures the service. Deploy with the Fly.io CLI:
cd server
fly launch --generate-name --no-deploy # first time only
fly deploySet environment variables for production (or edit server/fly.toml):
fly secrets set NODE_ENV=production
fly secrets set ALLOWED_ORIGINS=https://veeyds.vercel.app
fly secrets set API_BASE_URL=https://veeyds-api.fly.devThe multi-stage Dockerfile installs Python, pip, and ffmpeg via apt, installs yt-dlp via pip (for YouTube JS interpreter support), then downloads the static binaries via scripts/install-bins.sh as a fallback β all during the Docker build.
Connect the repository on Vercel and configure:
| Setting | Value |
|---|---|
| Root Directory | client |
| Build Command | pnpm run build |
| Output Directory | dist |
VITE_API_URL (env) |
https://veeyds-api.fly.dev |
The client/vercel.json handles SPA routing rewrites and sets security headers automatically.
The UI is built around a custom design system defined entirely in client/src/app.css:
- Palette: Grayscale base (
#0A0A0A/#F5F5F0) with peach accent (#E8724Alight,#FFAB91dark) - Typography: Syne for headings, Bricolage Grotesque for body, JetBrains Mono for metadata
- Motion: Framer Motion throughout,
cubic-bezier(0.16, 1, 0.3, 1)as the standard easing - Primitives: All interactive elements (select, dialog, toast) are custom-built on Radix UI β no raw browser defaults
- All user-supplied URLs are sanitized before being passed to yt-dlp as arguments (no shell interpolation)
- yt-dlp is spawned with
shell: falseβ no shell injection surface - Per-route in-memory rate limiting with
Retry-Afterheaders - CORS restricted to
ALLOWED_ORIGINSin production - Security headers (
X-Frame-Options,X-Content-Type-Options,Referrer-Policy) set on all Vercel responses - Temp files for muxed downloads are deleted immediately after the response stream closes
Built by Habib Adebayo.