Curated Work / downly
Downly
A public Instagram media downloader, in 13 languages.
SHADCN/UI
SEO

Overview
Downly is a Next.js 16 web app that turns a public Instagram link into a real file download — reels, photo and video posts, IGTV, multi-item carousels and stories. There is no account, no browser extension and no credential handling: it only ever touches content a logged-out visitor can already see. Extraction runs server-side through a chain of interchangeable providers tried in priority order, and every media URL the client receives is a signed, expiring link to the app's own /api/media proxy rather than an Instagram CDN address. Layered on top is the product work — in-browser MP3 extraction, carousel ZIP bundling, resolution picking, an installable PWA that registers itself in the OS share sheet, and 13 fully pre-rendered locales including right-to-left Arabic. It runs on React 19 with strict TypeScript, Tailwind v4, shadcn/ui and next-intl, and is deployed on Vercel at downly.aviralyadav.dev.
A production Instagram downloader for public reels, posts, stories and carousels — media is extracted server-side through a swappable provider chain, and the browser only ever receives signed, expiring, same-origin links, so a raw CDN URL never reaches the client.
Key Features
- Downloads public reels, posts, IGTV, stories and multi-item carousels in original quality.
- Real file downloads through a signed, expiring, same-origin streaming proxy — never a raw CDN link.
- In-browser MP3 extraction and carousel ZIP bundling, with no server-side transcoding.
- Swappable extraction: providers are tried in priority order and can be replaced without touching the API or UI.
- 13 locales with right-to-left Arabic, every language tree pre-rendered at build time.
- Installable PWA with an OS share target, so a reel can be shared straight from Instagram into Downly.
Why I Built This
The interesting part of a downloader isn't fetching a URL — it's that the browser physically cannot do this job. Instagram's CDN rejects requests without a matching Referer, and the HTML download attribute is ignored cross-origin, so a link pointed straight at the CDN either fails outright or opens a video in a new tab instead of saving it. Every working solution has to put a server in the middle, and the moment you do that you have built something that fetches an arbitrary URL chosen by a stranger. That is the actual engineering problem: a proxy that will fetch on a user's behalf without ever becoming a way to make the server fetch something else.
The second reason was that extraction is unreliable by nature. Instagram changes shape constantly, datacentre IPs get blocked, and any single scraping method is one deploy away from dead. I wanted the app to treat that as the normal case rather than an exception — several independent strategies behind one interface, tried in order, with the failure that is most useful to the user being the one that surfaces. And because a downloader's audience is global by default, it ships in thirteen languages from the start instead of having i18n bolted on afterwards.
Architecture Overview
Dependencies flow one way: app/ to services/ to lib/, with types/ shared by all three. Route handlers are deliberately thin HTTP shells — they validate, rate-limit and delegate — and nothing in app/ ever talks to Instagram. All domain orchestration lives in services/, every foundation (link signing, the SSRF guard, the rate limiter, validated env, typed errors) lives in lib/, and the entire extraction path is marked server-only so it cannot be bundled into the browser even by accident.
The load-bearing piece is the normalized contract. Every successful extraction becomes a DownloadResult in which each media URL has already been rewritten into a signed /api/media link, and services/media/normalize.ts is the single place that enforces it. Because the invariant holds there, nothing downstream has to think about it — which is also why the CSP can keep img-src and media-src to 'self' and never allowlist Instagram's CDN at all.
rawUrl
→ platformRegistry.resolve() # which platform owns this host?
→ adapter.extract() # parse the URL, then run the provider chain
→ normalizeExtraction() # raw CDN media → signed /api/media links
→ DownloadResult
app/
[locale]/ # 13 locale trees, all pre-rendered at build time
api/download/route.ts # POST: validate → rate-limit → resolveDownload()
api/media/route.ts # GET : signed streaming proxy (the download seam)
api/health/route.ts # GET : provider + signing status (200 / 503)
services/
download-service.ts # orchestration only, no HTTP concerns
media/normalize.ts # THE place raw CDN URLs become signed links
platforms/
index.ts # register a new platform HERE — one line
registry.ts # URL → adapter
instagram/
url.ts # parser shared by client Zod + server adapter
media-id.ts # base64url shortcode → numeric media pk (BigInt)
providers/ # rapidapi(10) embed(15) graphql(25) mock(99)
lib/
security/signing.ts # HMAC-SHA256 links, constant-time verify
security/url-guard.ts # fail-closed SSRF host allowlist
rate-limit/ http/fetcher.ts errors/app-error.ts config/env.ts
types/ # DownloadResult · PlatformAdapter · ExtractionProvider- Routing app/[locale] with all 13 locales pre-rendered via generateStaticParams; setRequestLocale runs in the layout and again in every page, because next-intl v4 needs each unit to opt into static rendering independently.
- Extraction A PlatformRegistry maps a URL to a PlatformAdapter, and the adapter runs a ProviderChain of interchangeable strategies sorted by numeric priority rather than by array order.
- Security foundation lib/security holds the two independent controls: HMAC-SHA256 link signing with constant-time verification, and a fail-closed host allowlist that is re-checked on every redirect hop.
- Config Env is validated once by a Zod schema and memoised; a bad value logs the offending key and falls back to a default, so a missing optional provider key degrades the app instead of taking it down.
- Client surface Almost everything is server-rendered — components/downloader is the one client island, and the hooks under hooks/ (download, media-download, zip, audio-extract, clipboard) are the only places browser work happens.
The Provider Chain: extraction as a swappable seam
Extraction is the part guaranteed to rot, so it is the part built to be replaced. An ExtractionProvider is just a name, a priority, isAvailable() and extract(); the chain filters to whatever is currently available, sorts by ascending priority, and returns the first result that actually contains media. A provider that comes back with zero items counts as a failure rather than a success, so the next one still gets its turn. Four exist today: a paid RapidAPI endpoint at priority 10 that only switches on when both its key and host are set, the public embed-page scraper at 15, Instagram's own JSON API at 25, and a fixture provider at 99 that is opt-in and never serves in production.
The embed scraper is my favourite piece of the whole thing. Instagram serves two completely different pages from the same /embed/captioned/ URL depending on User-Agent: a modern Chrome string gets a React shell with media_id set to null and no media in it at all, while a crawler string gets a server-rendered page whose script tag carries the real MP4 — inside a double-JSON-encoded blob that has to be parsed twice, once to unescape the string literal and again to get the object. Identifying honestly as facebookexternalhit/1.1 is the entire difference between the page having media and not.
- Priority, not array order The chain sorts by each provider's numeric priority, so reordering the strategy is a number change rather than a refactor, and disabling one is an env flag.
- Most specific error wins When everything fails, the loudest error is usually the least useful — a scraper that got IP-blocked reports UPSTREAM_ERROR while an API provider correctly reports PRIVATE_OR_UNAVAILABLE. A specificity ranking re-throws the second.
- Decoding the shortcode locally A post's shortcode is its numeric primary key in a base64url alphabet, so decoding it in-process — with BigInt, since the pks are roughly 19 digits and overflow a JS Number — reaches the media info endpoint without depending on a doc_id that Instagram rotates every few weeks.
- One parser, two callers The URL parser is dependency-free and deliberately not server-only, so the same rules back the client-side Zod refinement and the server adapter and cannot drift apart.
- A new platform is one line Adding YouTube or TikTok means writing a PlatformAdapter and appending a single .register() call; the routes, the request schema and every component depend only on the interface and the normalized result.
The Signed Media Proxy
/api/media exists because the browser cannot do the job: the CDN rejects requests without a matching Referer, and the download attribute is ignored cross-origin, so the bytes have to come from our own origin with Content-Disposition: attachment for the save to actually happen. That makes it a route which fetches a URL out of a query string — the classic shape of an SSRF hole — so it carries three independent controls, each of which holds even if the other two fail.
First the signature: an HMAC-SHA256 over the encoded url, filename, expiry and disposition, compared in constant time. Signing the encoded bytes sidesteps canonicalization mismatches, and putting disposition inside the payload means a client cannot quietly flip a preview into a forced download. Second the host allowlist, which fails closed and is re-checked on every redirect hop so an open redirect on an allowed host cannot bounce the fetch inward; it also refuses non-HTTPS, embedded credentials and raw IP literals, which closes the 169.254.169.254 metadata pivot even if the signing key leaks. Third the size ceiling, enforced both on the declared content-length and by counting bytes through a TransformStream that severs the stream if a length-less chunked response runs past the cap. Every signature failure — missing, malformed, expired, wrong — returns the same FORBIDDEN, so probing it teaches you nothing.
- Connect timeout, not transfer timeout The abort timer covers only the wait for response headers and is cleared the instant they arrive, so large videos stream freely instead of being killed mid-download by a blanket deadline.
- Content-type gate The upstream type must start with video/, image/, audio/ or application/octet-stream, so the proxy can never relay HTML or JavaScript from our own origin — and every one of these checks runs before a byte is returned, because status and headers freeze the moment the first chunk flushes.
- Range passthrough The client's Range header is forwarded and a 206 comes back as a 206 with its content-range intact, so seeking and resuming a large video both work.
- Its own bucket Proxying is bandwidth-expensive, so it rate-limits separately from extraction under a media-prefixed key rather than sharing the extraction quota.
Failing Honestly: the error taxonomy
Twelve error codes, each with a fixed HTTP status and two separate messages: an internal one that is only ever logged, and a public one that is the only text a client ever sees. That split matters more than it sounds, because the difference between telling someone a post is private, deleted, rate-limited or simply unsupported is the difference between a useful app and one that says something went wrong. AppError.from() folds runtime failures into the same vocabulary automatically, so an AbortError becomes TIMEOUT and an undici socket failure becomes NETWORK_ERROR without any call site having to know.
The other half is refusing to be killed by the platform. Vercel's function ceiling is 60 seconds, and datacentre IPs are blocked by Instagram often enough that a fully stalled provider chain is a normal Tuesday. So extraction races a 45-second deadline of its own: whichever settles first wins, the timer is always cleared in a finally so it cannot leak, and the user gets a clean typed TIMEOUT instead of an opaque platform 504 with no body to parse.
- One sanitization chokepoint Every route serializes through the same jsonError helper, which coerces any thrown value into an AppError, logs the internal message and puts only the code, public message and retry hint on the wire.
- Cheap checks first Content-length, content-type and the rate limit are all evaluated before the body is even read, so a flood costs a map lookup rather than an outbound request.
- Health that means something /api/health reports ok only when a usable provider exists and the signing secret is present, and returns 503 otherwise, so a misconfigured-but-running deploy reads as down to an uptime monitor instead of green.
- No raw IPs stored The rate-limit key is a truncated SHA-256 of the first forwarded-for hop, so throttling works without ever keeping an address.
Reach: 13 Locales, PWA & AEO
Every page lives under app/[locale] and all thirteen language trees are generated at build time, Arabic included with dir flipped to RTL on the html element. next-intl's middleware handles locale detection, the NEXT_LOCALE cookie and hreflang Link headers, while the layout maps the active locale to an OpenGraph territory code and lists the other twelve as alternates — so crawlers read all thirteen as one entity rather than thirteen pages competing with each other.
The most useful integration is also the smallest. The web manifest registers a share target, so sharing a reel from inside the Instagram app opens Downly with the link attached as a query param. Instagram dumps that URL inside a free-form text blob rather than the dedicated url field, so the parser scans the text for URL substrings, strips the tracking params and the trailing punctuation that clings to pasted links, and pre-fills the box with a canonical URL ready to go.
- Static by declaration setRequestLocale runs in the layout and in every page, because Next renders them as separate units and a page that omits it silently falls back to dynamic rendering.
- Sitemap and head agree The sitemap's hreflang map, x-default included, is generated from the same routing helpers the pages use, so the two hreflang signals physically cannot disagree.
- llms.txt from the message catalog The AI-facing summary is assembled at build time from the English translation file, so it describes the how-it-works steps, FAQ and features that are actually rendered and can never drift from them.
- Tight CSP Because all media is same-origin, img-src and media-src never list Instagram's CDN; the Google Analytics hosts are appended only when a measurement ID is set, so a deployment without analytics keeps the strictest possible policy.
Key Decisions
- A signed proxy over direct CDN links — Handing the browser a raw CDN URL is simpler, but it 403s without a Referer and the download attribute is ignored cross-origin, so it would not actually download anything. Routing through a signed same-origin proxy is what makes the button work, and it is what lets the CSP stay tight.
- A provider chain over one extraction method — Any single method is one Instagram change away from dead. Sorting interchangeable providers by priority turns a broken strategy into a fallback rather than an outage, and swapping one in is a file plus a number.
- Decoding the shortcode locally over relying on a doc_id — Instagram rotates the GraphQL doc_id every few weeks, but the media pk is recoverable from the shortcode with a base64url decode — so the most durable endpoint stays reachable with no moving part to chase.
- MP3 and ZIP in the browser over server-side work — Transcoding audio and zipping carousels server-side would mean CPU, temp storage and a job queue for something the client can do to bytes it already has. The adapter therefore declares no audio capability at all, and the encoding happens after the download rather than before it.
- Degrade on bad config over crashing — Env validation logs the offending key and falls back to a schema default, so a missing optional provider key never takes the site down. The one deliberate exception is the signing secret, which throws in production because a predictable key would let anyone mint proxy URLs for arbitrary hosts.
- A 45-second race under a 60-second ceiling — Rather than hoping extraction finishes in time, it competes with a timer set comfortably inside the platform's limit, which converts an opaque infrastructure 504 into a typed TIMEOUT the client can actually explain to a user.
What I Learned
- A user-supplied URL is the whole threat model — Once a route fetches an address a stranger chose, signing, a fail-closed allowlist re-checked on every redirect, and a content-type gate stop being nice-to-haves. Layering three independent controls is what meant no single mistake could be fatal.
- The User-Agent was the feature — An embed page that stubbornly returned nothing turned out to hinge on one header: the same URL serves a hollow React shell to a browser and the real media to a crawler. The most valuable thing I added afterwards was a log line that says which of the two came back.
- Errors are product surface — Splitting internal from public messages and ranking failures by specificity did more for how the app feels than any UI work, because 'this post is private' and 'we could not reach Instagram' are genuinely different answers to genuinely different problems.
- Streaming inverts the order of your checks — Status and headers freeze the instant the first chunk flushes, so anything you might want to refuse on has to be decided before you return the body — and a cap you can only enforce mid-stream has to sever the connection rather than politely reply.
Tech Stack
SHADCN/UI
SEOLike what you see?
Explore more work or get in touch.
