Drop into your stack. One URL, no SDK.
Every endpoint is one HTTPS call. Point at a source, add query params, ship the result. No token to manage, no upload step, no package to install — if your framework can render an <img> or call fetch, it can run img apis.
Every integration is the same URL.
Your account has a namespace. Once you bind a custom domain, it disappears from the URL entirely. src is either a full URL on your allowed-external list or a named source you configured in the dashboard.
Cache hits and errors never bill. Every URL is deterministic, so a warm CDN serves the same params for free, for a year.
https://<namespace>.imgapis.com/<endpoint>?src=<source>&<params>
# or, with a custom domain:
https://images.acme.com/<endpoint>?src=<source>&<params>/infoMetadata, colors, palette, BlurHash — one response.
0.5/info/{variant}Single-field shortcut: metadata · colors · blurhash. Shared cache.
0.5/transformResize, reformat, blur, mark, background-remove.
1/gridCompose 2–5 sources into a single image.
2 / source/placeholder/{kind}Render a real BlurHash or solid-color placeholder image.
0.5Custom loader, wired through next/image.
The cleanest fit is a custom image loader. Next handles srcset, lazy loading and placeholders. img apis handles the actual bytes.
Configure the loader
Point Next at a tiny loader file. No build plugin, no SDK.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
loader: 'custom',
loaderFile: './libs/img-apis-loader.ts'
}
};
module.exports = nextConfig;Implement it in five lines
Build a URL from the loader args. Format negotiation is automatic from the Accept header — no format= needed unless you want to pin one.
// libs/img-apis-loader.ts
const HOST = 'https://acme.imgapis.com';
type LoaderArgs = {
quality?: number;
src: string;
width: number;
};
const loader = ({ quality, src, width }: LoaderArgs) => {
const params = new URLSearchParams({
src,
w: String(width),
quality: String(quality ?? 80)
});
return `${HOST}/transform?${params.toString()}`;
};
export default loader;Use next/image like normal
Pass either a full URL or a configured named source as src. Everything else is stock Next.
// app/page.tsx
import Image from 'next/image';
const Page = () => {
return (
<Image
alt="Hero"
height={900}
sizes="(max-width: 768px) 100vw, 1600px"
src="mycdn/marketing/hero.jpg"
width={1600}
/>
);
};
export default Page;Render server-side BlurHash placeholders
Fetch /info/blurhash on the server, decode it to a data URL, hand it to placeholder="blur". Or skip decoding entirely and point placeholder at /placeholder/blurhash as a real image.
// app/photo/[id]/page.tsx
import { decode } from 'blurhash';
import Image from 'next/image';
const HOST = 'https://acme.imgapis.com';
type Props = { params: { id: string } };
const fetchBlurDataUrl = async (src: string) => {
const url = new URL(`${HOST}/info/blurhash`);
url.searchParams.set('src', src);
const res = await fetch(url, { next: { revalidate: 86400 } });
const { blurhash } = (await res.json()) as { blurhash: string };
// decode → small canvas → data URL (helper omitted)
return blurhashToDataUrl(blurhash, 32, 32);
};
const PhotoPage = async ({ params }: Props) => {
const src = `mycdn/photos/${params.id}.jpg`;
const blurDataUrl = await fetchBlurDataUrl(src);
return (
<Image
alt="Photo"
blurDataURL={blurDataUrl}
height={1200}
placeholder="blur"
src={src}
width={1600}
/>
);
};
export default PhotoPage;Color-theme a route from /info/colors
The /info family shares its cache — calling /info/colors on a URL already touched by /info is a cache hit. Zero credits.
// app/article/[slug]/page.tsx
const HOST = 'https://acme.imgapis.com';
const ArticlePage = async ({ params }: { params: { slug: string } }) => {
const heroSrc = `mycdn/articles/${params.slug}/hero.jpg`;
const url = new URL(`${HOST}/info/colors`);
url.searchParams.set('src', heroSrc);
url.searchParams.set('pairs', 'true');
const res = await fetch(url, { next: { revalidate: 86400 } });
const { palette } = await res.json();
return (
<article style={{ background: palette.darkVibrant.hex }}>
…
</article>
);
};Same loader contract, getStaticProps / getServerSideProps.
Same image loader file, same envvars. Server-side data fetching moves into the pages-router data hooks.
Wire the loader (identical)
No difference from App Router for the image config.
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './libs/img-apis-loader.ts'
}
};Fetch /info inside getServerSideProps
The fetch shape doesn't change between routers — only where it lives.
// pages/photo/[id].tsx
import type { GetServerSideProps } from 'next';
import Image from 'next/image';
const HOST = 'https://acme.imgapis.com';
type Props = {
blurDataUrl: string;
src: string;
};
export const getServerSideProps: GetServerSideProps<Props> = async ({
params
}) => {
const src = `mycdn/photos/${params!.id}.jpg`;
const url = new URL(`${HOST}/info/blurhash`);
url.searchParams.set('src', src);
const res = await fetch(url);
const { blurhash } = await res.json();
const blurDataUrl = blurhashToDataUrl(blurhash, 32, 32);
return { props: { blurDataUrl, src } };
};
const PhotoPage = ({ blurDataUrl, src }: Props) => {
return (
<Image
alt="Photo"
blurDataURL={blurDataUrl}
height={1200}
placeholder="blur"
src={src}
width={1600}
/>
);
};
export default PhotoPage;No loader plumbing — just an <img>.
Build the URL, drop it on src. For a placeholder, point the background-image at /placeholder/blurhash and let the real image paint over it.
A <Photo> component you actually want to use
The BlurHash placeholder sits as the element background. The browser swaps to the transformed image when ready — no JS, no library.
// src/components/photo.tsx
const HOST = 'https://acme.imgapis.com';
type Props = {
alt: string;
height: number;
src: string;
width: number;
};
const Photo = ({ alt, height, src, width }: Props) => {
const params = new URLSearchParams({
src,
w: String(width),
h: String(height),
fit: 'cover',
quality: '80'
});
const transformed = `${HOST}/transform?${params.toString()}`;
const placeholder = `${HOST}/placeholder/blurhash?src=${encodeURIComponent(src)}&w=32&h=32`;
return (
<img
alt={alt}
height={height}
loading="lazy"
src={transformed}
style={{
backgroundImage: `url(${placeholder})`,
backgroundSize: 'cover'
}}
width={width}
/>
);
};
export default Photo;Responsive srcset is a one-liner
Generate an entry per breakpoint. Every URL is cached independently for a year.
const widths = [400, 800, 1200, 1600];
const srcSet = widths
.map(w => {
return `${HOST}/transform?src=${encodeURIComponent(src)}&w=${w} ${w}w`;
})
.join(', ');Resolve /info in the loader.
Fetch metadata where Remix already wants you to fetch data. Hand the JSON to the route component as-is.
Loader + route component
The full response — colors, palette, BlurHash, dimensions — is yours in one round-trip.
// app/routes/photo.$id.tsx
import { json, useLoaderData } from 'react-router';
import type { LoaderFunctionArgs } from 'react-router';
const HOST = 'https://acme.imgapis.com';
export const loader = async ({ params }: LoaderFunctionArgs) => {
const src = `mycdn/photos/${params.id}.jpg`;
const url = new URL(`${HOST}/info`);
url.searchParams.set('src', src);
const res = await fetch(url);
const info = await res.json();
return json({ info, src });
};
const PhotoRoute = () => {
const { info, src } = useLoaderData<typeof loader>();
return (
<figure style={{ background: info.palette.muted.hex }}>
<img
alt="Photo"
height={info.metadata.height}
src={`${HOST}/transform?src=${encodeURIComponent(src)}&w=1600`}
width={info.metadata.width}
/>
</figure>
);
};
export default PhotoRoute;A 20-line provider for <NuxtImg>.
@nuxt/image ships a custom provider system. Drop a tiny adapter in and <NuxtImg> handles the rest.
Write the provider
// providers/img-apis.ts
import { joinURL } from 'ufo';
import type { ProviderGetImage } from '@nuxt/image';
export const getImage: ProviderGetImage = (
src,
{ modifiers = {}, baseURL } = {}
) => {
const { format, height, quality, width } = modifiers;
const params = new URLSearchParams({ src });
if (width) { params.set('w', String(width)); }
if (height) { params.set('h', String(height)); }
if (format) { params.set('format', format); }
if (quality) { params.set('quality', String(quality)); }
return {
url: joinURL(baseURL!, `/transform?${params.toString()}`)
};
};Register it
// nuxt.config.ts
export default defineNuxtConfig({
image: {
providers: {
imgApis: {
provider: '~/providers/img-apis',
options: { baseURL: 'https://acme.imgapis.com' }
}
}
}
});Use <NuxtImg> as normal
<template>
<NuxtImg
alt="Hero"
height="900"
provider="imgApis"
src="mycdn/marketing/hero.jpg"
width="1600"
/>
</template>Build URLs server-side, in +page.svelte.
Use the load function for /info work, then render a plain <img> with the public host envvar.
Load /info
// src/routes/photo/[id]/+page.server.ts
import type { PageServerLoad } from './$types';
const HOST = 'https://acme.imgapis.com';
export const load: PageServerLoad = async ({ params }) => {
const src = `mycdn/photos/${params.id}.jpg`;
const url = new URL(`${HOST}/info`);
url.searchParams.set('src', src);
const res = await fetch(url);
const info = await res.json();
return { info, src };
};Render with palette-driven theming
<!-- src/routes/photo/[id]/+page.svelte -->
<script lang="ts">
const HOST = 'https://acme.imgapis.com';
export let data;
$: transformed = `${HOST}/transform?src=${encodeURIComponent(data.src)}&w=1600`;
</script>
<figure style="background: {data.info.palette.darkVibrant.hex}">
<img alt="Photo" src={transformed} />
</figure>Server-rendered by default.
Astro resolves /info at build time for static pages, on demand for dynamic routes. Either way, the request is one fetch.
Frontmatter fetch + JSX
For dynamic routes, set export const prerender = false and Astro fetches on each request — still cached at the img apis edge.
---
// src/pages/photo/[id].astro
const HOST = 'https://acme.imgapis.com';
const { id } = Astro.params;
const src = `mycdn/photos/${id}.jpg`;
const infoRes = await fetch(`${HOST}/info?src=${encodeURIComponent(src)}`);
const info = await infoRes.json();
const transformed = `${HOST}/transform?src=${encodeURIComponent(src)}&w=1600`;
---
<figure style={`background: ${info.palette.muted.hex}`}>
<img
alt="Photo"
height={info.metadata.height}
src={transformed}
width={info.metadata.width}
/>
</figure>Copy, paste, ships.
No build step, no framework. Two snippets and you have a placeholder-to-real-image swap with no JavaScript.
An ordinary <img>
This is the entire integration when you don't need placeholders. The URL is the API.
<img
alt="Hero"
height="900"
src="https://acme.imgapis.com/transform?src=mycdn/marketing/hero.jpg&w=1600&quality=85"
width="1600"
/>Add a BlurHash background that fades on load
The /placeholder/blurhash endpoint emits a real PNG the browser renders directly. No decoder library.
<img
alt="Hero"
loading="lazy"
onload="this.style.background='none'"
src="https://acme.imgapis.com/transform?src=mycdn/hero.jpg&w=1600"
style="background: url('https://acme.imgapis.com/placeholder/blurhash?src=mycdn/hero.jpg&w=32&h=32') center/cover"
/>Same call shape where fetch works.
Node, Bun, Workers, Deno — the request is one fetch and the response is JSON. No streaming or decoding plumbing.
Fetch /info once, type it forever
const HOST = 'https://acme.imgapis.com';
const fetchInfo = async (src: string) => {
const url = new URL(`${HOST}/info`);
url.searchParams.set('src', src);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`img apis: ${res.status}`);
}
return res.json() as Promise<{
blurhash: string;
colors: {
area: number;
hex: string;
hsl: string;
intensity: number;
rgb: string;
}[];
metadata: {
byteSize: number;
contentType: string;
format: string;
height: number;
width: number;
};
palette: Record<
| 'darkMuted'
| 'darkVibrant'
| 'lightMuted'
| 'lightVibrant'
| 'muted'
| 'vibrant',
{ hex: string }
>;
}>;
};What server-side /info unlocks
- Upload gating. Reject SVGs, enforce minimum dimensions, refuse oversized files before they hit your storage.
- Index-time color extraction. Persist palette JSON next to the asset row so list pages skip the per-image call.
- Pre-computed placeholders. Store the BlurHash string in the same row — render placeholders with zero img apis traffic at view time.
Notes that apply everywhere.
The five things worth keeping in your head, no matter which framework you picked above.
Headers worth a peek
x-img-apis-cache: HIT or MISS tells you whether the call was billed. Inspect content-type to confirm the negotiated format.
CORS, not keys
Browser requests must come from an origin on your allow-list. Server-to-server with no Origin passes through. No token header — auth is origin plus namespace.
src allow-listing
src=https://… must match your allowed-external-sources. Named sources (src=mycdn/path) bypass the check — they’re already gated by being configured in your account.
Long-lived URLs
Transforms, grids and placeholders are immutable and cached for one year. Build URLs deterministically (same params, same URL) so the edge actually hits.
No SDK to drag along
Every example is plain fetch + URLSearchParams. The whole API surface is the URL — nothing to upgrade when we ship new features.
200 free credits.No card. No catch.
No tokens to manage, no migrations to run. Make your first API call before the kettle boils.
7-day trial · No card · No auto-charge · One-line bill