Syndicating Photos to Flickr with POSSE
POSSE — Publish on your Own Site, Syndicate Elsewhere — is a principle from the IndieWeb movement. The idea is simple: your site is the source of truth. External platforms like Flickr get a copy, not the original.
I’ve been on Flickr since 2012. Rather than managing two separate upload workflows, I built a script that treats my site as the canonical source and syncs everything outward automatically.
What it does
scripts/upload-to-flickr.js runs in five phases on every invocation:
- Delete — any photo in the tracking file that no longer has a local sidecar gets removed from Flickr
- Verify — all tracked photos are checked against the Flickr API; any deleted directly on Flickr are reset and queued for re-upload
- Move — photos whose collection has changed are removed from the old album and added to the new one
- Upload — any sidecar without a
flickrIdgets uploaded with title, description, tags, and GPS - Retry — photos that uploaded successfully but failed album assignment are added to their album
After upload, the flickrId is written back into the sidecar JSON:
{
"id": "2025-10-06-121017",
"flickrId": "55158155787",
"title": ["Golden Temple Bell in Sunlit Foliage", ...],
...
}
The sidecar is the single source of truth for both local metadata and the Flickr reference. No separate platforms.json needed.
Tracking
A local scripts/flickr-tracking.json maps photo IDs to their Flickr state:
{
"2025-10-06-121017": {
"flickrId": "55158155787",
"photosetId": "72177720324172664",
"collectionSlug": "travels/asia/chiang-mai",
"uploadedAt": "2026-03-21T10:00:00.000Z"
}
}
This file is gitignored — it’s machine state, not content. The collectionSlug field is what enables move detection: if the photo’s current path no longer matches the stored slug, the script updates the album assignment on Flickr automatically.
Metadata from sidecars
Tags are built from three sources: the photo’s own tags, the camera model, and the collection path segments:
const tags = [
...sidecar.tags,
sidecar.exif.camera.toLowerCase().replace(/\s+/g, "-"),
...collectionSlug.split("/"),
]
.map((t) => (t.includes(" ") ? `"${t}"` : t))
.join(" ");
A Singapore photo tagged ["cityscape", "rain"] shot on an iPhone 13 Pro Max gets: cityscape rain iphone-13-pro-max travels asia singapore.
GPS coordinates are stored in the sidecar as a DMS string and parsed to decimal degrees before being passed to flickr.photos.geo.setLocation:
// "18 deg 48' 16.92" N, 98 deg 55' 18.92" E" → { lat: "18.804700", lon: "98.921922" }
function parseDMS(location) {
const pattern =
/(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([NS]),\s*(\d+)\s*deg\s*(\d+)'\s*([\d.]+)"\s*([EW])/i;
const m = location.match(pattern);
const lat = (Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600)
* (m[4].toUpperCase() === "S" ? -1 : 1);
const lon = (Number(m[5]) + Number(m[6]) / 60 + Number(m[7]) / 3600)
* (m[8].toUpperCase() === "W" ? -1 : 1);
return { lat: lat.toFixed(6), lon: lon.toFixed(6) };
}
Photosets from collections
Collection titles are read from each index.md frontmatter:
function readCollectionTitle(slug) {
const mdPath = path.join("src/content/photos/collections", slug, "index.md");
const content = fs.readFileSync(mdPath, "utf8");
const m = content.match(/^title:\s*(.+)$/m);
return m ? m[1].trim() : slug.split("/").at(-1);
}
If a photoset with that title already exists on Flickr, the photo is added to it. If not, a new one is created. Flickr photosets are flat — no nesting — so the leaf collection name is used: travels/asia/chiang-mai becomes “Chiang Mai”.
Deletion sync
The tracking file holds a record of every uploaded photo. On each run, the script compares its keys against local sidecar paths. Any ID in tracking without a local file means the photo was deleted from the site:
const localIds = new Set(sidecarPaths.map((p) => path.basename(p, ".json")));
const deletedIds = Object.keys(tracking).filter((id) => !localIds.has(id));
for (const id of deletedIds) {
await flickr("flickr.photos.delete", { photo_id: tracking[id].flickrId });
delete tracking[id];
saveTracking(tracking);
}
Deleting a file locally removes it from Flickr on the next run.
Move detection
When a photo is moved to a different collection, the tracking file’s collectionSlug no longer matches the file’s current path. The script removes the photo from the old photoset and adds it to the new one:
const movedPhotos = sidecarPaths.filter((p) => {
const entry = tracking[path.basename(p, ".json")];
return entry?.collectionSlug && entry.collectionSlug !== collectionSlugFromPath(p);
});
Syncing stats back
A second script, scripts/update-flickr.js, fetches view counts, faves, and comment counts for all public photos and writes them back into the sidecar JSONs:
sidecar.flickr = { views, faves, comments };
fs.writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), "utf8");
These stats are available at build time. The photo detail page shows them alongside the Flickr link when flickrId is present:
{sidecar.flickrId && (
<a href={`https://www.flickr.com/photos/${FLICKR_USER_ID}/${sidecar.flickrId}/`}>
View on Flickr
{sidecar.flickr && (
<span>
{sidecar.flickr.views} views · {sidecar.flickr.faves} faves · {sidecar.flickr.comments} comments
</span>
)}
</a>
)}
OAuth
Flickr requires OAuth 1.0a with delete permissions for full sync. Since existing OAuth dance tools had issues with browser detection, I wrote a minimal scripts/flickr-auth.js that handles the flow entirely in the terminal — request token, authorization URL printed to stdout, PIN entered manually, access token exchanged and printed ready to paste into .env.local.
The workflow
pnpm flickr:dry-run # preview what would be uploaded or deleted
pnpm flickr:upload # sync: delete removed, verify, move, upload new
pnpm flickr:update # pull stats back into sidecars
The site stays the source. Flickr stays in sync.