Guides / Tutorials
Add dog breed images to a Next.js app
By the Dog Image API team. Updated .
In the App Router, fetch the breed catalog in a server component with a revalidate time, render nine preview images on a breed page, and download full-size PNGs with a separate Node script that sends your key. Previews can be hot-linked. Full-size files should end up in your own storage.
This guide uses Next.js 15 or later with the app directory. Server components are async by default, so the data code stays short.
Fetch the catalog on the server
Put the base URL and two helpers in lib/dogs.ts. The revalidate option tells Next.js to cache the response and refresh it at most once a day:
// lib/dogs.ts
export const API = "https://dogimage.dev";
export type Variant = { slug: string; name: string; kind: string };
export type BreedSummary = {
slug: string;
name: string;
crossbreed: boolean;
default_variant: string;
variants: Variant[];
};
export type View = { id: string; label: string };
export async function getCatalog(): Promise<{ views: View[]; breeds: BreedSummary[] }> {
const res = await fetch(`${API}/api/v1/breeds`, { next: { revalidate: 86400 } });
if (!res.ok) throw new Error(`Catalog request failed: ${res.status}`);
return res.json();
}
export function previewUrl(breed: string, view: string, variant = "default", size = 512) {
return `${API}/api/v1/images/${breed}/${variant}/${view}?size=${size}`;
}
The catalog already includes the nine views with their labels, so you never hardcode them.
A list page
app/dogs/page.tsx lists every breed with a link to its detail page:
// app/dogs/page.tsx
import Link from "next/link";
import { getCatalog } from "@/lib/dogs";
export default async function DogsPage() {
const { breeds } = await getCatalog();
return (
<main>
<h1>Dog breeds</h1>
<ul>
{breeds.map((b) => (
<li key={b.slug}>
<Link href={`/dogs/${b.slug}`}>{b.name}</Link>
</li>
))}
</ul>
</main>
);
}
A breed page with nine views
The detail route reads the slug, asks for the single breed, and maps over the views. generateStaticParams builds every breed page at build time, and unknown slugs return a 404 page.
// app/dogs/[breed]/page.tsx
import { notFound } from "next/navigation";
import { API, getCatalog, previewUrl } from "@/lib/dogs";
export async function generateStaticParams() {
const { breeds } = await getCatalog();
return breeds.map((b) => ({ breed: b.slug }));
}
export default async function BreedPage({
params,
}: {
params: Promise<{ breed: string }>;
}) {
const { breed } = await params;
const res = await fetch(`${API}/api/v1/breeds/${breed}`, {
next: { revalidate: 86400 },
});
if (res.status === 404) notFound();
if (!res.ok) throw new Error(`Breed request failed: ${res.status}`);
const data = await res.json();
const { views } = await getCatalog();
return (
<main>
<h1>{data.name}</h1>
{data.summary?.text && <p>{data.summary.text}</p>}
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 12 }}>
{views.map((v) => (
<figure key={v.id} style={{ margin: 0 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl(breed, v.id)}
alt={`${data.name}, ${v.label}, AI-generated illustration`}
width={256}
height={256}
loading="lazy"
style={{ width: "100%", height: "auto" }}
/>
<figcaption>{v.label}</figcaption>
</figure>
))}
</div>
<p>
<a href={`${API}/dogs/${breed}`}>{data.name} image: Dog Image API</a>
{data.summary?.url && (
<>
{" "}
Summary from <a href={data.summary.url}>Wikipedia</a>.
</>
)}
</p>
</main>
);
}
On Next.js 14 and earlier, params is a plain object, so drop the Promise type and the await.
A plain <img> is used because the preview URL is a redirect to a WebP file. If you prefer next/image, add the host to images.remotePatterns in next.config.ts and test that the redirect resolves in your setup.
Two licensing notes. The breed summary text comes from Wikipedia under CC BY-SA 4.0, so keep the attribution link. The credit link to Dog Image API is required for previews.
Download full-size PNGs into your project
Previews are 512 and 1024 pixels. Full-size files are 2048 x 2048 transparent PNGs, available with a paid plan. Each one is 1 to 7 MB, so you do not want to fetch them per visitor. Download them once instead, from a script that runs on your machine or in CI.
Create scripts/download-dogs.mjs:
// scripts/download-dogs.mjs
import { mkdir, writeFile, access } from "node:fs/promises";
import path from "node:path";
const API = "https://dogimage.dev";
const key = process.env.DOG_IMAGE_API_KEY;
if (!key) throw new Error("Set DOG_IMAGE_API_KEY first");
const breeds = process.argv.slice(2);
if (breeds.length === 0) breeds.push("pug", "beagle");
const catalog = await (await fetch(`${API}/api/v1/breeds`)).json();
const viewIds = catalog.views.map((v) => v.id);
for (const breed of breeds) {
const outDir = path.join("public", "dog-images", breed);
await mkdir(outDir, { recursive: true });
for (const view of viewIds) {
const file = path.join(outDir, `${view}.png`);
try {
await access(file);
continue; // already stored, skip
} catch {}
let res;
for (let attempt = 0; attempt < 3; attempt++) {
res = await fetch(`${API}/api/v1/images/${breed}/default/${view}?size=full`, {
headers: { Authorization: `Bearer ${key}` },
});
if (res.status !== 502 && res.status !== 503) break; // retry only these two
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(`${breed}/${view}: ${res.status} ${body.error?.code ?? ""}`);
}
await writeFile(file, Buffer.from(await res.arrayBuffer()));
console.log(`saved ${file} (quota left: ${res.headers.get("X-Quota-Remaining")})`);
}
}
Run it with the key in the environment:
DOG_IMAGE_API_KEY=your-key node scripts/download-dogs.mjs pug samoyed
The script skips files it already has, retries the two retryable errors (502 file_unavailable and 503 plan_check_failed), and stops with the error code on anything else. A 402 means no active plan, and a 429 means the monthly quota is used up. Each breed costs nine images against a plan that counts distinct images per month, so check the pricing page before you loop over the whole catalog.
If you keep the key in .env.local, run the script with node --env-file=.env.local scripts/download-dogs.mjs pug (Node 20.6 or later), and never prefix the variable with NEXT_PUBLIC_. The file public/dog-images/pug/front.png is then served by your own app at /dog-images/pug/front.png.
Read the license before you ship. Full-size files can go into your own product without credit, but you cannot republish them as an image library or dataset.
Questions
Should a Next.js page load the full-size PNGs from the API at request time?
No. Full-size files are meant to be downloaded once and stored on your side, and the API should not be hot-linked in pages. Use a script to download them, then serve your own copies.
Does fetching the same full-size image twice use my quota twice?
No. A plan counts distinct full-size images per calendar month in UTC, so fetching the same image again in the same month is free. A failed fetch with a 502 is not counted.
Where should the API key live in a Next.js project?
In a server-only environment variable named DOG_IMAGE_API_KEY, without the NEXT_PUBLIC_ prefix. Never import it into a client component.
Next steps
- API docs: endpoints, keys, limits and errors.
- All dog breeds, each with nine views.
- Pricing for full-size transparent PNG files.
- A dog breed card with Tailwind CSS
- A dog breed gallery in SvelteKit
- A dog breed picker in Angular
- A dog breed selector in Vue 3