Right-click any pin, choose "Open image in new tab", and look at the URL. You'll get something like this:
https://i.pinimg.com/236x/3f/8a/c1/3f8ac1d29e4b7f0a6c5d2e1b9a8f7c6d.jpg
That 236x is not decoration. It means you are looking at a copy resized to 236 pixels wide, the size the home grid needs. The original file the creator uploaded is still sitting on the same CDN, one folder away.
I went down this rabbit hole while building a media extractor, and the URL structure turned out to be one of the cleanest CDN designs I've worked with. Here's everything I learned.
Anatomy of a pinimg.com URL
| Part | Example | What it means |
|---|---|---|
| Host | i.pinimg.com |
Image CDN (videos live on v1.pinimg.com) |
| Size folder | 236x |
Which resized variant you get |
| Shard folders | 3f/8a/c1 |
First 6 characters of the hash, split in pairs |
| Filename | 3f8ac1d2...jpg |
Content hash of the file + extension |
The shard folders are a classic trick: splitting by hash prefix keeps any single directory from holding millions of files. The useful part for us is that the hash never changes between sizes. Only the size folder does.
The size folders
| Folder | Typical use |
|---|---|
75x75_RS |
Square avatar-style crop |
140x, 170x
|
Tiny previews |
236x |
Home feed grid |
474x, 564x
|
Larger grid / board views |
736x |
Pin close-up page |
1200x |
Large display |
originals |
The uploaded file, unresized |
The x with no height means "this width, keep the aspect ratio". So upgrading a thumbnail is a one-line regex:
const SIZE_RE = /\/(?:\d+x\d*(?:_RS)?|originals)\//;
function toSize(url, size = 'originals') {
return url.replace(SIZE_RE, `/${size}/`);
}
toSize('https://i.pinimg.com/236x/3f/8a/c1/3f8ac1d2.jpg');
// -> https://i.pinimg.com/originals/3f/8a/c1/3f8ac1d2.jpg
There's one catch: originals doesn't always exist in the extension you expect. A pin served as .jpg in the grid may have been uploaded as .png, and some originals return 403. So in practice you walk down a ladder until something answers:
const LADDER = ['originals', '1200x', '736x', '564x'];
async function bestAvailable(url) {
for (const size of LADDER) {
const candidate = toSize(url, size);
const res = await fetch(candidate, { method: 'HEAD' });
if (res.ok) return candidate;
}
return url; // fall back to what we were given
}
Run this server-side. From the browser, CORS will get in your way.
How big is the difference, really?
Want to see it on your own pin? This script pulls one pin at every size and prints the resolution and file size:
import { imageSize } from 'image-size';
const base = 'https://i.pinimg.com/236x/3f/8a/c1/3f8ac1d2.jpg'; // your pin
for (const size of ['236x', '564x', '736x', 'originals']) {
const res = await fetch(toSize(base, size));
if (!res.ok) { console.log(size, res.status); continue; }
const buf = Buffer.from(await res.arrayBuffer());
const { width, height } = imageSize(buf);
console.log(size, `${width}x${height}`, `${Math.round(buf.length / 1024)} KB`);
}
The jump from 736x to originals is where it matters for designers. Zoom into a moodboard reference at 736x and fine texture turns to mush; the original holds up.
Videos play by different rules
Videos live on a separate host, v1.pinimg.com, and come in two flavours:
https://v1.pinimg.com/videos/mc/hls/ab/cd/ef/<hash>.m3u8 # adaptive stream
https://v1.pinimg.com/videos/mc/720p/ab/cd/ef/<hash>.mp4 # single file
The HLS playlist is what the player uses; it switches quality on the fly and splits audio and video into segments. The MP4 is a single progressive file, which is what you actually want to save.
You can't upgrade videos with a regex the way you can with images. The available variants are listed in the pin's data as a video_list object, keyed by names like V_720P or V_HLSV4 (the exact keys change over time). Pick the largest MP4 from whatever is there:
function pickBestMp4(videoList) {
return Object.values(videoList)
.filter(v => v.url?.endsWith('.mp4'))
.sort((a, b) => b.width * b.height - a.width * a.height)[0] ?? null;
}
An honest note: you can only get what was uploaded. If a creator posted a 720p clip, no tool can turn it into 1080p. Anything claiming otherwise is upscaling, not extracting.
Where the media URLs actually live
To upgrade a URL you first need one. A pin page exposes its media in three places, and each fails differently:
| Source | What it gives you | How it breaks |
|---|---|---|
Hydration JSON (__PWS_DATA__ script) |
Everything: all image sizes, video_list, story pages, carousel slots |
Structure changes often; key paths move between deploys |
JSON-LD (application/ld+json) |
Main image, sometimes contentUrl for video |
Missing on some pin types; usually one image only |
OpenGraph (og:image, og:video) |
A mid-size image, sometimes a video URL | Almost always present, rarely the best quality |
The richest source is the most fragile, and the most stable source is the poorest. So you don't pick one; you chain them.
function extractMedia(html) {
return fromHydration(html) ?? fromJsonLd(html) ?? fromOpenGraph(html);
}
Tier 3, OpenGraph. The safety net. Grab the tag, then upgrade it:
function fromOpenGraph(html) {
const m = html.match(/<meta[^>]+property="og:image"[^>]+content="([^"]+)"/);
return m ? { type: 'image', url: toSize(m[1]) } : null;
}
Tier 2, JSON-LD. Structured, but there can be several blocks and not all are about the pin:
function fromJsonLd(html) {
const blocks = html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g);
for (const [, raw] of blocks) {
try {
const data = JSON.parse(raw);
if (data.contentUrl) return { type: 'video', url: data.contentUrl };
const img = data.image?.url ?? data.image;
if (typeof img === 'string') return { type: 'image', url: toSize(img) };
} catch { /* skip malformed block */ }
}
return null;
}
Tier 1, hydration JSON. Don't hard-code a path like props.initialReduxState.pins[id]; it will break on the next deploy. Search by shape instead. Images carry an images object with an orig entry, videos carry video_list:
function findFirst(node, predicate) {
if (!node || typeof node !== 'object') return null;
if (predicate(node)) return node;
for (const value of Object.values(node)) {
const hit = findFirst(value, predicate);
if (hit) return hit;
}
return null;
}
function fromHydration(html) {
const m = html.match(/<script[^>]+id="__PWS_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (!m) return null;
const data = JSON.parse(m[1]);
const video = findFirst(data, n => n.video_list);
if (video) return { type: 'video', url: pickBestMp4(video.video_list)?.url };
const pin = findFirst(data, n => n.images?.orig?.url);
return pin ? { type: 'image', url: pin.images.orig.url } : null;
}
Searching by shape keeps working when Pinterest reshuffles its state tree; hard-coded paths are the first thing to break.
Edge cases that will bite you
-
Short links.
pin.it/xxxxis a redirect. Resolve it first (fetch(url, { redirect: 'follow' })and readres.url) before parsing anything. - Idea / Story pins. One pin, many pages. Each page is its own block with its own image or video, so return an array, not a single URL.
-
Carousel pins. Same idea: several slots, each with its own
imagesobject. Upgrade every slot separately. -
GIFs that aren't GIFs. Many animated pins are delivered as short MP4 loops for performance. The real
.gifmay only exist underoriginals, so check both. -
Extension mismatch. The grid shows
.jpg; the original may be.pngor.webp. Iforiginals404s, try the other extensions before stepping down the size ladder. - Private or removed pins. No hydration data, no JSON-LD, just a login wall. Fail fast with a clear message instead of retrying.
Building it for real
Once the pieces worked, I wrapped them into a small serverless resolver: resolve the link, fetch the page once, run the three-tier extractor, upgrade the URL, then cache the result by pin ID so repeat requests never touch Pinterest again.
I turned this logic into a small free tool, Pinvexa, if you want to see the original-size upgrade in action. Paste any pin link and compare what you get against the 236x thumbnail.
Respect the platform
Just because a file is reachable doesn't mean it's yours. A few rules I stick to:
- Only work with public pins. If something sits behind a login, leave it there.
- Saving a reference for your own moodboard is one thing; reposting someone's work as your own is another. Credit the creator.
- Cache aggressively and rate-limit yourself. One page fetch per pin is plenty.
- Read Pinterest's terms and honour takedown requests if you run anything public.
What I took away from this
Pinterest's CDN is a nice lesson in boring, predictable design: a content hash, sharded folders and a size prefix. Once you see the pattern, a whole class of "why is this image so blurry?" problems disappears.
Have you reverse-engineered another platform's CDN? Instagram, Behance, Dribbble? I'd love to compare notes in the comments.
I'm Prince, an SEO consultant who also builds small web tools. I write about the engineering behind them. My latest is Pinvexa, a Pinterest media toolkit.
Top comments (0)