Skip to content
Pix4Less
BrowseCreatePricingDeveloper docsSign inCreate account

Guide · Next.js

Add stock images to a Next.js site with the Pix4Less API

Build a small search page that finds AI-generated photos on the server, shows their previews with next/image, and downloads the original of the one you pick. Your API key never reaches the browser.

Next.js App RouterServer-side keynext/image
Get an API keyRead the API docs

Last reviewed 28 September 2026 against the OpenAPI spec.

Aerial view of dense limestone pinnacles with green trees in the crevices.
An unlikely rhythmAI-generated

Before you start

How the pieces fit.

The flow has three steps, and each one has a different cost and a different kind of URL:

  1. Search with POST /v1/images/search. It needs your API key and uses credits, so it runs on your server only.
  2. Preview with the URLs in the search result. They are relative paths on https://pix4less.com, unsigned and stable, so your pages can use them directly. They are never the original file.
  3. Download with POST /v1/images/{id}/download. It returns a short-lived signed URL for the full-resolution original, which you fetch once and keep yourself.

There is a free browse endpoint too, GET /v1/images. It returns 12 images per page in a shuffled order and takes only page and seed, so it cannot take a query or filters; use it for a “discover” strip, and search for everything else.

You will need Node.js and a Next.js App Router project. The examples use fetch, so there is no SDK to install. Next.js 16 passes searchParams and route params as promises, and the code below awaits them.

Step 1

Keep the key on the server.

Create an account and make an API key in the dashboard. Put it in .env.local. Do not prefix it with NEXT_PUBLIC_, which would ship it to every visitor. You can also send it as X-API-Key; if both headers are present, X-API-Key wins.

.env.local
# .env.local (never commit this file, never prefix the key with NEXT_PUBLIC_)
PIX4LESS_API_KEY=paste-your-key-here

Step 2

Search from a Server Component.

Here is the request body and the same call in curl. Only query is needed; per_page accepts 1 to 12, and unknown fields are rejected.

Request body for POST /v1/images/search
{
  "query": "quiet coffee shop interior, morning light",
  "filters": {
    "orientation": "landscape"
  },
  "per_page": 6
}
Terminal
curl https://pix4less.com/v1/images/search \
  -H "Authorization: Bearer $PIX4LESS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"quiet coffee shop interior, morning light","filters":{"orientation":"landscape"},"per_page":6}'

Response fields. data (the images), pagination, search_type, session_id, shuffle_cycle_restarted and request_id. Each image has id, title, description, tags, url, renditions (web and/or thumbnail, each with url, width, height, format, byte_size), license (code, url, status), content_warnings, and the original’s width, height and format. Text searches add match_confidence.

The module below wraps that call. Two details matter: width and height at the top level describe the original, so it reads the size of the preview from renditions; and it does not retry a failed search, because a search is not idempotent and a retry can be charged twice.

The import "server-only" line makes the build fail if a Client Component ever imports this file, which keeps the key out of the browser bundle. It comes from the server-only package (npm install server-only); Next.js documents it as optional.

src/lib/pix4less.ts
// src/lib/pix4less.ts (server only: import it from Server Components and route handlers)
import "server-only";

const ORIGIN = "https://pix4less.com";
const TTL_MS = 60 * 60 * 1000;

type Rendition = { url: string; width: number; height: number };
type ApiImage = {
  id: string;
  title: string;
  description: string;
  renditions: { web?: Rendition; thumbnail?: Rendition };
  license: { code: string; url: string | null; status: string };
  content_warnings?: string[];
};

export type StockImageData = {
  id: string;
  src: string; // absolute preview URL on the Pix4Less origin
  width: number; // preview size, not the size of the original
  height: number;
  alt: string;
  title: string;
  license: { code: string; url: string | null };
};

export function apiKey(): string {
  const key = process.env.PIX4LESS_API_KEY;
  if (!key) throw new Error("PIX4LESS_API_KEY is not set");
  return key;
}

const cache = new Map<string, { expires: number; images: StockImageData[] }>();

export async function searchStockImages(query: string, perPage = 6): Promise<StockImageData[]> {
  const cacheKey = query.trim().toLowerCase() + "|" + perPage;
  const hit = cache.get(cacheKey);
  if (hit && hit.expires > Date.now()) return hit.images;

  const response = await fetch(ORIGIN + "/v1/images/search", {
    method: "POST",
    headers: { Authorization: "Bearer " + apiKey(), "Content-Type": "application/json" },
    body: JSON.stringify({ query, filters: { orientation: "landscape" }, per_page: perPage }),
    cache: "no-store",
  });
  if (!response.ok) {
    // Search is not idempotent: report the failure, do not retry automatically.
    const body = (await response.json().catch(() => null)) as { error?: { code?: string; request_id?: string } } | null;
    throw new Error("Pix4Less search failed: HTTP " + response.status + " " + (body?.error?.code ?? "") + " " + (body?.error?.request_id ?? ""));
  }

  const { data } = (await response.json()) as { data: ApiImage[] };
  const images = data.flatMap((image): StockImageData[] => {
    const preview = image.renditions.web ?? image.renditions.thumbnail;
    if (!preview) return [];
    return [{
      id: image.id,
      src: new URL(preview.url, ORIGIN).toString(), // the API returns a relative path
      width: preview.width,
      height: preview.height,
      alt: image.description,
      title: image.title,
      license: { code: image.license.code, url: image.license.url },
    }];
  });
  cache.set(cacheKey, { expires: Date.now() + TTL_MS, images });
  return images;
}

Step 3

Render previews with next/image.

Preview files come in fixed sizes (web and thumbnail) and are already web-ready, so there is nothing for Next.js to resize. The module above turns each relative preview path into an absolute URL, and the page passes it to next/image with the unoptimized prop, which serves the file as it is. The width and height props come from the preview’s own size and reserve the right space, so nothing shifts while it loads.

Because the image is not proxied through Next.js’s optimizer, you do not need an images.remotePatterns entry for pix4less.com. If you would rather have Next.js resize and re-encode previews, drop unoptimized and allow that host under images.remotePatterns in next.config.ts instead.

The page is a Server Component that reads searchParams, searches on the server and renders plain markup. The description becomes the alt text. It only searches when there is a query, and it shows a plain message when nothing matches (an empty search is not charged).

src/app/stock/page.tsx
// src/app/stock/page.tsx
import Image from "next/image";
import { searchStockImages } from "@/lib/pix4less";

export default async function StockPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
  const { q } = await searchParams;
  const query = q?.trim().slice(0, 200);
  // A search costs credits: only run it for a real query, on the server.
  // Every request with a new ?q= spends credits: put auth or a rate limit in front of this page.
  const images = query ? await searchStockImages(query) : [];

  return (
    <main>
      <form>
        <input name="q" defaultValue={query} placeholder="Describe the image you need" />
        <button type="submit">Search</button>
      </form>
      {query && images.length === 0 ? <p>No match. Try a broader description.</p> : null}
      <div>
        {images.map((image) => (
          <figure key={image.id}>
            {/* Previews are fixed-size files served as they are, so skip Next's optimizer. */}
            <Image
              unoptimized
              src={image.src}
              alt={image.alt}
              width={image.width}
              height={image.height}
              sizes="(max-width: 768px) 100vw, 33vw"
            />
            <figcaption>{image.title}</figcaption>
          </figure>
        ))}
      </div>
    </main>
  );
}

A public page that searches on ?q= spends credits on every request. Anyone who can reach it can spend your balance just by changing the query, and the example’s cache only helps for a query it has already seen. Put your own login or a rate limit in front of it, or search once when you author the page and cache the results (see Step 5).

If your site sets a Content-Security-Policy header, add the preview origin to its img-src directive:

Content-Security-Policy header (img-src directive)
img-src 'self' https://pix4less.com

Step 4

Download an original on demand.

Search results never grant the original. When a person picks an image, your server asks for a download. The route below authorizes the download, fetches the bytes without your API key, and saves the file. The signed URL expires quickly and must not be logged or stored.

src/app/api/stock-images/[id]/download/route.ts
// src/app/api/stock-images/[id]/download/route.ts
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { NextResponse } from "next/server";
import { apiKey } from "@/lib/pix4less";

const ORIGIN = "https://pix4less.com";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const EXTENSIONS: Record<string, string> = { "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", "image/avif": "avif" };

// Every call can spend credits: put your own authentication in front of this route.
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  if (!UUID.test(id)) return NextResponse.json({ error: "Invalid image id" }, { status: 400 });

  // 1. Ask Pix4Less to authorize the original. A new image is charged once per account.
  const grant = await fetch(ORIGIN + "/v1/images/" + id + "/download", {
    method: "POST",
    headers: { Authorization: "Bearer " + apiKey() },
    cache: "no-store",
  });
  if (!grant.ok) return NextResponse.json({ error: "Download not authorized" }, { status: grant.status === 402 ? 402 : 502 });
  const { data } = (await grant.json()) as { data: { url: string; expires_at: string | null; owned: true } };

  // 2. Fetch the bytes right away, WITHOUT the API key. The signed URL is short-lived: do not log or store it.
  const file = await fetch(data.url, { cache: "no-store" });
  if (!file.ok) return NextResponse.json({ error: "Original could not be fetched" }, { status: 502 });
  const extension = EXTENSIONS[(file.headers.get("content-type") ?? "").split(";")[0]] ?? "bin";

  // 3. Keep the file yourself. Local disk keeps the example small; use your own object storage and CDN in production.
  const directory = join(process.cwd(), "stock-originals");
  await mkdir(directory, { recursive: true });
  const name = id + "." + extension;
  await writeFile(join(directory, name), Buffer.from(await file.arrayBuffer()));
  return NextResponse.json({ id, saved: name });
}

Response fields. A successful authorization returns data.url, data.expires_at, data.owned and request_id. A new image is charged once per account, at the price of its quality tier; asking again for an image you already own does not charge again. Pass ?rendition=upscaled_4k for the 4K upscale when one exists. Downloads of some images are withheld and return 403 download_unavailable.

The example writes to local disk to stay short. On a serverless host, write to your own object storage and put a CDN in front of it instead.

Step 5

Cache and credit.

  • Store the id, not a URL. The id is the durable identity. GET /v1/images/{id} returns the details and previews again for free, and a fresh download link can be requested whenever you need one.
  • Do not search on every page view. The in-memory cache in the example keeps results for an hour, but it resets on every cold start. For pages that show the same picture to everyone, search once when you author the page and save the chosen id, title, description and license in your CMS or database.
  • Host the original yourself. Previews may be reused as they are, but the signed download URL is for fetching once. Do not hotlink it.
  • Record the license. Every image carries license.code and license.url. Keep them next to the image, and read the license to see what your use needs. The API does not add an attribution step of its own.
  • Review before you publish. Images are AI-generated and not every one has been reviewed by a person, so check each one, and pass safe_search: false only if you mean to include images with content warnings.

Live price book

What it costs.

Search and download are separate charges, read live from the price book when this page renders. One quick search plus one new catalog download currently uses 2 credits. Downloads are priced by quality tier (HD: 3 credits, 4K: 10 credits). Never hardcode these numbers in your app: read GET /v1/pricing, which is public and free.

A search is charged only after a valid search returns at least one result. Errors worth handling separately are 401 (missing or invalid key), 402 (not enough credits, with credits_required, balance and topup_url in the details), 429 (wait for Retry-After) and 503 (search_timeout or catalog_unavailable, nothing charged). See the error reference. To let an AI agent do this work instead, read Add image search to Claude and Cursor with MCP.

The complete contract is in the OpenAPI spec, and the image search API overview has the price table.

Ready to try it?

Create an account, make an API key in the dashboard, and run your first search. Images are AI-generated; review each image and its license for your use case.

Get an API keyOpenAPI specAPI docs
Pix4Less

AI-generated imagery for your next idea.
Check each image and its license before use.

BrowseCreateCollectionsJournalPricingCompareDeveloper APIMCP serverDeveloper docsLicenseTermsPrivacyImpressumCancel contracts hereContact