Build a geolocation image watermarker with Transloadit

A photo can contain GPS coordinates in its EXIF metadata. We can read those coordinates locally, ask a reverse geocoder for a place label, then use Transloadit to add that label to the image. Reading EXIF and reverse geocoding are separate operations: a geocoder does not extract image metadata.

Before we start
Use Node.js 24 or newer and a JPEG you own that contains GPS metadata. This example deliberately does not promise support for every camera RAW or HEIC variant. Install the actual libraries:
npm install exifr@7.1.3 @transloadit/node@4.12.0
Store the resulting lockfile with your project. The code is a one-photo CLI, not a public service. It sends coordinates to the public Nominatim service and uploads the photo to Transloadit. Do not use sensitive locations, photos without permission, or automated bulk workloads.
Read the Nominatim usage policy. Supply an identifying contact, run only one instance at a time, leave at least one second between lookups, and reuse previous results instead of repeatedly querying the same photo. For a product or batch pipeline, choose a provider whose contract supports that workload or host your own geocoder.
Code
Put this complete program in geo-watermarker.mjs. Supply TRANSLOADIT_AUTH_KEY,
TRANSLOADIT_AUTH_SECRET, GEOCODER_CONTACT and PHOTO through your local environment.
GEOCODER_CONTACT should be a real contact email for the application operator, not a secret.
import { open } from 'node:fs/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { Transloadit } from '@transloadit/node'
import exifr from 'exifr'
function required(name) {
const value = process.env[name]
if (!value) throw new Error('Missing configuration.')
return value
}
async function readPhoto(path) {
const handle = await open(path, 'r')
try {
const stat = await handle.stat()
if (!stat.isFile() || stat.size === 0 || stat.size > 8 * 1024 * 1024) {
throw new Error('Use a JPEG no larger than 8 MiB.')
}
const bytes = Buffer.alloc(stat.size)
let offset = 0
while (offset < bytes.length) {
const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, null)
if (bytesRead === 0) throw new Error('Photo changed while reading.')
offset += bytesRead
}
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error('Use a JPEG photo.')
return bytes
} finally {
await handle.close()
}
}
async function placeFor(latitude, longitude, contact) {
if (!Number.isFinite(latitude) || !Number.isFinite(longitude) ||
Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
throw new Error('Photo has no valid GPS coordinates.')
}
const url = new URL('https://nominatim.openstreetmap.org/reverse')
url.searchParams.set('format', 'jsonv2')
url.searchParams.set('lat', String(latitude))
url.searchParams.set('lon', String(longitude))
url.searchParams.set('zoom', '10')
url.searchParams.set('accept-language', 'en')
await delay(1000)
const response = await fetch(url, {
headers: { 'User-Agent': 'PhotoWatermarker/1.0 (' + contact + ')' },
signal: AbortSignal.timeout(10_000),
})
if (!response.ok) throw new Error('Location lookup failed.')
const data = await response.json()
if (typeof data?.display_name !== 'string') throw new Error('No place was found.')
// Keep the label bounded and plain text before sending it to the image renderer.
const label = data.display_name.replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
if (!label || label.length > 160) throw new Error('Place label needs manual review.')
return label
}
async function main() {
const photo = required('PHOTO')
const authKey = required('TRANSLOADIT_AUTH_KEY')
const authSecret = required('TRANSLOADIT_AUTH_SECRET')
const contact = required('GEOCODER_CONTACT')
if (!/^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/.test(contact)) {
throw new Error('Provide a contact email for the geocoder.')
}
const bytes = await readPhoto(photo)
const gps = await exifr.gps(bytes)
const address = await placeFor(gps?.latitude, gps?.longitude, contact)
const transloadit = new Transloadit({ authKey, authSecret })
const result = await transloadit.createAssembly({
waitForCompletion: true,
timeout: 120_000,
signal: AbortSignal.timeout(120_000),
uploads: { photo: bytes },
params: {
fields: { address },
steps: {
':original': { robot: '/upload/handle' },
watermarked: {
robot: '/image/resize',
use: ':original',
result: true,
format: 'jpg',
strip: true,
text: [{
text: '${fields.address}',
size: 24,
font: 'Ubuntu',
color: '#ffffff',
background_color: '#000000',
align: 'center',
valign: 'bottom',
y_offset: -12,
}],
imagemagick_stack: 'v3',
},
},
},
})
const output = result.results?.watermarked?.[0]?.ssl_url
if (result.ok !== 'ASSEMBLY_COMPLETED' || typeof output !== 'string') {
throw new Error('No completed watermarked image was returned.')
}
const url = new URL(output)
if (url.protocol !== 'https:') throw new Error('Unexpected result URL.')
console.log('Result: ' + url.href)
console.log('Place data: OpenStreetMap contributors, https://www.openstreetmap.org/copyright')
}
main().catch(() => {
console.error('Watermarking failed. Check configuration, photo metadata and service availability.')
process.exitCode = 1
})
Info extraction
Exifr reads the JPEG's GPS tags and converts them to decimal latitude and longitude. Zero is a valid coordinate, so the program tests numeric ranges rather than truthiness. Missing coordinates stop the workflow before any upload.
The bounded buffer is used for both metadata extraction and the upload, avoiding a second read of a file that could have changed. Keep the local photo immutable while reading it.
Geo encoding
Reverse geocoding associates coordinates with a nearby mapped place; it is not proof of where a photo was taken. EXIF can be absent, edited or inaccurate. The example asks for a coarse place label, but it still sends the original coordinates to the provider.
The CLI prints OpenStreetMap attribution. Preserve the required attribution and licensing notices wherever you publish the derived place data. A terminal message alone is not attribution for a published image gallery. Review the returned label and shorten it yourself when necessary before using this pattern for a real photograph.
Encoding final result
The Auth Key and Auth Secret authenticate the server-side SDK. They are not storage Template Credentials. Never put the secret in browser code, a public repository or a shared terminal transcript.
The /image/resize Robot uses the
address as an Assembly Variable and returns one JPEG.
The strip option removes source metadata from the output, but the uploaded original and the
geocoder request have already exposed the location to those services. Review your data-handling
requirements before running the program.
Results
After configuring your environment, run:
node geo-watermarker.mjs
A successful Assembly prints a temporary result URL and the attribution notice. Download the result promptly or add an export Step for storage you control. Inspect the rendered label for clipping on your image dimensions; the fixed font size is an example, not a universal layout.

This demonstrates the integration boundary: local metadata extraction, an explicit location lookup and a managed watermarking operation. Automating it for a product also requires consent, suitable provider terms, retained lookup results, operational limits and a reviewed publication workflow.