Car images in React and Next.js
Mint signed URLs on the server, render a plain img, cache within the URL's lifetime and handle a 402.
Your API key never reaches the browser. Code that runs on your server, a Server Component, a Route Handler or a Server Action, mints signed delivery URLs with POST /api/v1/image-urls, 1 credit per URL when it is minted, and the browser loads them with a plain <img>, free until they expire. The examples use the Next.js App Router and the zero-dependency TypeScript SDK; any React framework that renders on a server follows the same steps.
1. Keep the key on the server
Keep the key in CAR_IMAGE_API_KEY: in your host's environment settings for deployments, and for development in .env.local, kept out of version control. Never give it the NEXT_PUBLIC_ prefix: Next.js inlines those variables into the JavaScript it sends to every browser. Start each module that reads the key with import "server-only", so a Client Component that imports it fails the build instead of shipping the key.
npm install @meterapp/car-image-sdkCAR_IMAGE_API_KEY=cimg_your_key_here2. Look the vehicle up once, render it by id
Name the car by its stable vehicle id (veh_…), not by a make and model typed from memory: the catalog files cars under its own names, so a name can answer 404 for a car it carries. Resolve the words or decode the VIN when a record is created, store the id with it (ids never change), and render by id from then on. Both lookups are free.
import "server-only";
import { CarImageClient } from "@meterapp/car-image-sdk";
const client = new CarImageClient({ apiKey: process.env.CAR_IMAGE_API_KEY });
/** "2018 Mazda MX-5 Miata" to "veh_…", or null when a person should choose. Free. */
export async function vehicleIdFromText(text: string): Promise<string | null> {
const { data } = await client.resolve(text);
const askedYear = data.extracted?.year;
if (data.confidence === "low" || (askedYear && askedYear !== data.params.year)) {
return null; // show data.candidates and let a person pick
}
return data.params.vehicle_id;
}
/** A full or partial VIN to "veh_…", or null when it maps to no catalog vehicle. Free. */
export async function vehicleIdFromVin(vin: string): Promise<string | null> {
const { data } = await client.decodeVin(vin);
return data.vehicle?.id ?? null;
}
/** "2024 Porsche 911" for alt text and headings: GET /api/v1/vehicles/{id}, public and free. */
export async function vehicleName(id: string): Promise<string> {
const { data } = await client.vehicle(id);
return `${data.year} ${data.make.name} ${data.model.name}`;
}medium confidence is an interpretation, such as a badge, trim words or a model name several makes share: render it, and say which vehicle you chose. A phrase that names nothing in the catalog answers 404, which the SDK throws as a CarImageError. A VIN can decode without a catalog match; ask for the make, model and year then. To map a table you already have, client.checkVehicles answers every row with the id to store, also for free. See resolve, backfilling a catalog and VIN decoding.
3. Mint signed URLs on the server
One call mints a batch of URLs with one debit, 1 credit per URL, and returns them in the order you asked. Without ttlSeconds a URL lives 3,600 seconds; the example asks for the longest, 7 days, so a cached page can hold it. The SDK sends an Idempotency-Key with every such call, so its own retry after a dropped connection replays the first answer instead of billing twice; pass idempotencyKey to extend that across processes, such as a job queue that can deliver the same task twice.
import "server-only";
import { CarImageClient, isCarImageError, type ImageParams } from "@meterapp/car-image-sdk";
const client = new CarImageClient({ apiKey: process.env.CAR_IMAGE_API_KEY });
/** 7 days, the longest a signed URL lives. */
export const IMAGE_URL_TTL_SECONDS = 604800;
/**
* Signed URLs for a batch of images, in one call and one debit: 1 credit per URL,
* charged now; loading them is free until they expire. Null when the account cannot pay.
*/
export async function signedCarImages(images: ImageParams[]): Promise<string[] | null> {
try {
const { data } = await client.createImageUrls(images, { ttlSeconds: IMAGE_URL_TTL_SECONDS });
return data.map((item) => item.url);
} catch (error) {
// 402: the balance is empty or the plan's monthly vehicle cap is reached. A retry cannot
// fix either, and nothing was charged: show a placeholder and tell a person.
if (isCarImageError(error) && error.status === 402) {
console.error(`Car Image API: ${error.detail} (request ${error.requestId})`);
return null;
}
throw error; // anything else fails this render; an ISR page keeps its last good version
}
}The body the SDK sends is the REST contract on Signed URLs; any HTTP client can make the same call.
4. Render it with a plain img
A Server Component awaits the URL and renders an ordinary image element. Ask for the box you will show (width and height, with trim so the car fills it) and format: "auto", which serves WebP or PNG to each browser, and put the same width and height on the element so the page does not shift while it loads. When the account cannot pay, the component shows a placeholder.
import type { View } from "@meterapp/car-image-sdk";
import { signedCarImages } from "@/lib/car-images";
export async function CarImage({
vehicle,
view = "front-3-4",
alt,
width = 600,
height = 400,
}: {
vehicle: string; // a veh_… id
view?: View;
alt: string;
width?: number;
height?: number;
}) {
const urls = await signedCarImages([{ vehicle, view, width, height, trim: true, format: "auto" }]);
if (!urls) {
return <div role="img" aria-label={alt} style={{ width, height, background: "#f4f4f5" }} />;
}
// eslint-disable-next-line @next/next/no-img-element -- the API already sized and encoded it
return <img src={urls[0]} alt={alt} width={width} height={height} loading="lazy" decoding="async" />;
}next/image for its layout props, pass unoptimized so the browser loads the signed URL as it is.A 402 means the balance is empty or the plan's monthly cap on distinct vehicles is reached, and nothing was charged. Retrying cannot fix either, so the placeholder stays until the page next renders: add credits or change the plan in the dashboard, where auto-reload keeps the balance from reaching zero. Both cases are in the error reference.
5. Cache no longer than the URL lives
A signed URL answers 403 once it expires, so nothing that stores your HTML may keep it longer: a prerendered page, an ISR page or a CDN in front of your site. Every render mints new URLs at a credit each, so the aim is to render rarely and never outlive the URLs.
- Revalidate well inside the TTL. With URLs that live 7 days, regenerate pages daily. Next.js answers the first request after the revalidate window with the stale page while it regenerates, so a page nobody opens for longer than the TTL shows that first visitor an expired URL.
- Renewing URLs for pages built once. For a static export, rarely visited pages or HTML a CDN keeps, mint with
renew: true. The URL keeps loading after it expires: the first load in each new window of the TTL bills one more credit (none for windows nobody opens), for 365 days after minting by default. Rebuild before then. See auto-renewing URLs. - Never mint per request. A page rendered on every visit that mints pays a credit per image per page view. If it must be dynamic, keep each URL with its
expires_at(in your database, a key-value store or the Next.js cache) and reuse it until shortly before it expires.
import { CarImage } from "@/components/car-image";
import { vehicleName } from "@/lib/vehicles";
// Regenerate at most once a day, well inside the 7 days the URLs live.
export const revalidate = 86400;
// No paths at build time: each page is rendered on its first visit, then cached.
// Without generateStaticParams the route renders, and mints, on every request.
export async function generateStaticParams() {
return [];
}
export default async function VehiclePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; // a veh_… id you stored
const name = await vehicleName(id);
return (
<main>
<h1>{name}</h1>
<CarImage vehicle={id} view="side" alt={`${name}, side view`} />
</main>
);
}// For HTML that outlives the TTL: the URLs keep loading after they expire.
const { data } = await client.createImageUrls(images, {
ttlSeconds: IMAGE_URL_TTL_SECONDS,
renew: true, // 1 more credit for each further window in which a URL is opened
});With Cache Components enabled, "use cache" and cacheLife replace revalidate; the rule is the same.
6. Client Components
A Client Component never holds the key. When a Server Component renders it, mint the URLs there and pass them down as props: a view picker gets every view it offers from one call.
import type { View } from "@meterapp/car-image-sdk";
import { signedCarImages } from "@/lib/car-images";
import { ViewPicker } from "./view-picker";
const GALLERY_VIEWS: View[] = ["front-3-4", "side", "rear-3-4", "front"];
// A Server Component: one call mints every view (1 credit each); the picker only switches.
export async function CarGallery({ vehicle, alt }: { vehicle: string; alt: string }) {
const urls = await signedCarImages(
GALLERY_VIEWS.map((view) => ({ vehicle, view, width: 800, height: 500, trim: true, format: "auto" })),
);
if (!urls) return null;
return <ViewPicker alt={alt} images={GALLERY_VIEWS.map((view, i) => ({ view, url: urls[i] }))} />;
}"use client";
import { useState } from "react";
export function ViewPicker({ alt, images }: { alt: string; images: { view: string; url: string }[] }) {
const [current, setCurrent] = useState(images[0]);
return (
<figure>
{/* eslint-disable-next-line @next/next/no-img-element -- a signed URL, sized by the API */}
<img src={current.url} alt={`${alt}, ${current.view} view`} width={800} height={500} />
{images.map((image) => (
<button key={image.view} type="button" aria-pressed={image === current} onClick={() => setCurrent(image)}>
{image.view}
</button>
))}
</figure>
);
}When the browser decides what to show, such as a paint picked in a configurator, let it call a Route Handler or Server Action of yours that mints. Mint only for vehicles your product shows: anyone can call that endpoint, and the terms rule out proxying raw access to the API. Keep each URL with its expires_at and hand out the same one until shortly before it expires, or every click costs a credit.
403 on other sites. It is hygiene, not identity: requests with no Referer or Origin pass. See Restrict URLs to your sites.