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.
Find your next car
Step 1 · Choose a make
48 of 48 makes
What it changes for the product
- 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
- 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
- Company
- Leaselab
- Footprint
- Multiple platforms
- Library
- More than 100 automotive brands
- Before Motomarks
- An extensive operational cost
What the team stopped doing
- 01
Hours chasing updated brand assets
The asset behind the URL is updated; the URL does not change
- 02
Resizing logos by hand for each placement
size=andtype=are query parameters - 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.”
What sourcing sixty badges yourself costs
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
Sports and exotic makes
Build it in four steps
“Brand list from the API, badge from the CDN, one helper for the URL. The picker stopped being a project.”
- 01
Fetch the make list on the server
The JSON API lists every published brand with an
id(the slug the CDN expects) and aname. 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)); } - 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 formd. 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}`; } - 03
Render the selector
A search input filters the list; each make is a button with
role="option"andaria-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> ); } - 04
Handle a missing badge
Variant availability differs by brand. Swap in the initial on
onErrorso 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.
Free includes 1,000 requests per day with an attribution link. Startup and Pro remove attribution.
More ways teams use Motomarks
Dealership website platforms
Teams building multi-rooftop dealer websites and inventory tools
Leasing and finance applications
Teams building lease and loan origination, quoting, and portfolio tools
Brand assets across multiple sites
Engineering leads running several automotive products or codebases