Build a vehicle make selector with brand logos

A searchable grid of makes, each with its manufacturer badge. The brand list comes from the JSON API, the badges come from the image CDN, and the selector below is the working result. The badge set, the part that used to take days to source and never stayed current, is one URL.

Jump to the code
cars.example/search

Find your next car

Step 1 · Choose a make

Volvo badgeSelected Volvoslug=volvo

48 of 48 makes

GET https://motomarks.io/img/volvo?type=badge&size=smCache-Control: public, max-age=86400
The finished component. Type to filter, click or press Enter to select. Every tile is a live 128px badge request; the footer shows the request for the selected make.

What it changes for the product

A make selector is small. The asset work behind it is not, and it recurs every time a brand launches or changes its mark. Four costs the URL removes, and what each one replaces.
The badge set is no longer the long pole
The component is an afternoon. Finding sixty badges, checking each one, trimming the whitespace, and exporting them at matching sizes is the part that used to eat the sprint. That part is one URL now.
Replaces sourcing and normalising a badge set
Fewer wrong-make records downstream
A badge beside the name is a second confirmation. Someone who taps the wrong row on a phone sees the wrong badge and fixes it before it reaches your database, your quote, or your claim.
Replaces cleanup of mis-keyed makes after the fact
A picker that keeps up with the market
New brands appear in the JSON API. An EV brand that launched last quarter is in the picker after the next revalidation, not after a release that someone had to remember to schedule.
Replaces a release every time a brand launches
One component across every flow
Search, onboarding, quoting, and claims all pick a make. Build the selector once against one URL pattern and reuse it; each flow changes only the size parameter.
Replaces four selectors carrying four logo sets

Five decisions that make it work

Small choices that decide whether the grid lines up, stays sharp, and reads correctly when something is missing.
Badge, not full logo
Badges are drawn to sit in a square, so a grid of them lines up. Full logos vary in width and fight the grid.
type=badge
One size up from the tile
A 40px tile requests the 128px asset, so it stays sharp on 2x and 3x displays without a srcset.
size=sm
WebP unless asked
The CDN serves WebP by default. Forty-eight tiles stay light; add format=png only where a renderer needs it.
format=webp
Name under every badge
The label carries meaning on its own, so a slow network or an unpublished variant never leaves an empty tile.
alt + visible label
Native button semantics
Each tile is a button with option semantics, so keyboard and screen-reader users get the same selector.
role="option"

How Leaselab took 100 brands off the operations budget

The make selector is usually the first place a 100-brand library shows up in a product, and the first place a stale one gets noticed. Leaselab maintains more than 100 brands across multiple platforms; this is what their CTO said changed when the library stopped being the team's job.
Company
Leaselab
Footprint
Multiple platforms
Library
More than 100 automotive brands
Before Motomarks
An extensive operational cost

What the team stopped doing

  1. 01

    Hours chasing updated brand assets

    The asset behind the URL is updated; the URL does not change

  2. 02

    Resizing logos by hand for each placement

    size= and type= are query parameters

  3. 03

    Applying one change across various systems

    Every platform reads the same URL, so one update reaches all of them

The JSON API gives us the brand list with the exact slug the CDN expects, so the make picker and the badge always agree. We revalidate daily and new brands appear without a deploy. That is the kind of dependency I want: predictable URLs, one key per product, and nothing to keep in the repository.
AC
Adrian Ciaschetti
CTO, Leaselab

What sourcing sixty badges yourself costs

The expensive part of a make selector is the badge set, not the component. Sixty makes at twenty minutes each is two and a half working days before a single rebrand happens. The defaults describe a web app and a mobile app sharing one set; put in your own numbers.

Leaselab maintains more than 100

Web app, mobile app

Rebrands, new brands, corrections

Source, resize, convert, commit, deploy, check

Find, trim, export sizes and formats, once

Salary, benefits, and overhead per hour

Motomarks plan to compare

Your numbers; change any input. First year = brands × minutes per brand + changes × codebases × hours per change. Each year after drops the one-time build. The model counts engineering time only; it leaves out CDN hosting, storage, and the cost of shipping an out-of-date mark.

Beyond the 48 makes above

The demo lists 48 published makes. Extend it with the rest of the library, or trim it to the makes your inventory carries. Each tile links to the brand's logo page; the browse page has the full index.

Build it in four steps

Server-side make list, one URL helper, the component, and a fallback. The examples use Next.js and React; the URLs are the same in any stack.
Brand list from the API, badge from the CDN, one helper for the URL. The picker stopped being a project.
AC
Adrian Ciaschetti
CTO, Leaselab
  1. 01

    Fetch the make list on the server

    The JSON API lists every published brand with an id (the slug the CDN expects) and a name. Fetch it with the secret key, revalidate daily, and pass the result to the client component as props.

    Trim the list to the makes your inventory carries if the selector should not show brands you never stock.

    lib/makes.ts
    export type Make = { slug: string; name: string };
    
    export async function getMakes(): Promise<Make[]> {
      const res = await fetch("https://api.motomarks.io/brands", {
        headers: { Authorization: `Bearer ${process.env.MOTOMARKS_SECRET_KEY}` },
        next: { revalidate: 86400 },
      });
      if (!res.ok) throw new Error(`Motomarks responded ${res.status}`);
    
      const brands: { id: string; name: string }[] = await res.json();
      return brands
        .map(({ id, name }) => ({ slug: id, name }))
        .sort((a, b) => a.name.localeCompare(b.name));
    }
  2. 02

    Build the badge URL once

    One function owns the CDN URL. The selector asks for sm; a summary card later in the flow asks for md. Nothing else about the request changes.

    lib/badge-url.ts
    export function badgeUrl(slug: string, size: "xs" | "sm" | "md" = "sm") {
      const params = new URLSearchParams({
        type: "badge",
        size,
        token: process.env.NEXT_PUBLIC_MOTOMARKS_KEY!,
      });
      return `https://motomarks.io/img/${slug}?${params}`;
    }
  3. 03

    Render the selector

    A search input filters the list; each make is a button with role="option" and aria-selected. Selection is controlled from the parent, so the chosen slug is ready for the next step of your flow.

    This is the component running in the demo above, minus the analytics call.

    make-selector.tsx
    "use client";
    
    import { useMemo, useState } from "react";
    import { badgeUrl } from "@/lib/badge-url";
    import type { Make } from "@/lib/makes";
    
    export function MakeSelector({
      makes,
      value,
      onChange,
    }: {
      makes: Make[];
      value: string | null;
      onChange: (slug: string) => void;
    }) {
      const [query, setQuery] = useState("");
      const visible = useMemo(() => {
        const q = query.trim().toLowerCase();
        return q ? makes.filter((m) => m.name.toLowerCase().includes(q)) : makes;
      }, [makes, query]);
    
      return (
        <div>
          <input
            type="search"
            value={query}
            onChange={(e) => setQuery(e.target.value)}
            placeholder="Search makes"
            aria-label="Search makes"
          />
          <ul role="listbox" aria-label="Vehicle make" className="make-grid">
            {visible.map((make) => (
              <li key={make.slug} role="presentation">
                <button
                  type="button"
                  role="option"
                  aria-selected={make.slug === value}
                  onClick={() => onChange(make.slug)}
                >
                  <img src={badgeUrl(make.slug)} alt="" width={40} height={40} loading="lazy" />
                  <span>{make.name}</span>
                </button>
              </li>
            ))}
          </ul>
        </div>
      );
    }
  4. 04

    Handle a missing badge

    Variant availability differs by brand. Swap in the initial on onError so the grid never shows a broken image, and keep the name label so nothing depends on the image loading.

    make-badge.tsx
    function MakeBadge({ slug, name }: Make) {
      const [failed, setFailed] = useState(false);
      if (failed) return <span className="make-initial">{name[0]}</span>;
      return (
        <img
          src={badgeUrl(slug)}
          alt=""
          width={40}
          height={40}
          loading="lazy"
          onError={() => setFailed(true)}
        />
      );
    }

Questions before you integrate

Should the tiles use the badge, the wordmark, or the full logo?

Badge for grids: it is drawn to fit a square, so tiles align. Use the wordmark for horizontal lists and tables, and the full logo for brand headers. All three are the same URL with a different type parameter, where the brand has that variant published.

Which size do I request for a 40px tile?

size=sm, which is 128px. That covers 2x and 3x displays without a srcset. Use size=xs (64px) for 16 to 24px badges in dense filters, and size=md (256px) for summary cards.

How do I keep the make list in sync with the library?

Fetch https://api.motomarks.io/brands on the server with a daily revalidation. The response includes the id (slug) and name of each published brand, and the changelog lists new brands and logo updates as they land.

Can I build this in Vue, Svelte, or plain HTML?

Yes. The component is a search input, a list of buttons, and an image URL. The CDN request is identical in any framework, and every brand page includes HTML, React, Next.js, Vue, Swift, and Flutter snippets for the same URL.

Does the Free plan cover a selector on a public search page?

Free includes 1,000 requests a day and requires an attribution link on your production site. A 48-tile selector is 48 image requests on an uncached page view, and browsers cache each badge for 24 hours, so a low-traffic page fits. Move to Startup or Pro as views grow.

What does building the badge set ourselves actually cost?

Sixty makes at twenty minutes each to find, check, trim, and export in the sizes you need is twenty hours before the component exists. Every rebrand or new brand afterwards is another change in every app that holds the files. Startup is $228 a year billed monthly, under three hours at a loaded cost of $85 an hour. Leaselab described maintaining more than 100 brands by hand as an extensive operational cost; the cost model on this page uses your numbers.

Ship the selector
with a free key.

Create a key, paste the component, and point it at the published make list. Selection returns the slug your next step needs, and the badge set never becomes a maintenance item.

Browse the published makes

Free includes 1,000 requests per day with an attribution link. Startup and Pro remove attribution.