A working reference implementation of shipping rate comparison on the Shippo API. Clone it, add a test key, and it runs: fill in an origin, a destination, and parcel dimensions, and see real carrier rates compared side by side — cheapest first, with the carriers that couldn't quote surfaced instead of hidden.
Live demo: shipkit-web.vercel.app
It is not a component library to copy piece by piece. The components are a
sliced vertical, not loose parts: RateCard only means something with a Rate,
which only exists because of normalize.ts, which only runs because of the route
handler. The value is in seeing the whole integration done carefully — typed
errors, money as integer cents, partial-failure handling — not in lifting one
file out. So: clone, key, run.
Built on entrepta, a dark-first design system, dogfooded here: the design system was built first, and shipkit is it applied to a new domain.
git clone <this-repo> shipkit && cd shipkit
npm install
# get a Shippo TEST key at goshippo.com (Settings → API), then:
cp .env.local.example .env.local
# edit .env.local and paste your shippo_test_... key
npm run dev # http://localhost:3000The key is read server-side only and never reaches the browser.
The layering rule, and the reason the components stay easy to reason about:
app/ routing, fetching, page composition
components/ rendering and UI-local state
lib/ data, money, and the Shippo wire format
Dependencies point downward only. lib/ never imports from components/;
components/ never imports from app/. The components are presentational —
data in, events out. RateList receives rates, it does not fetch them;
ShippingForm receives onSubmit, it does not call the API. The only thing that
knows /api/rates exists is the page's client island, app/rate-comparer.tsx.
That discipline is what keeps this maintainable and testable rather than a
tangle: the pure core (lib/) is tested against a saved fixture with no
mocking, and every component renders from a literal props object with no
provider. Here is what each one takes and depends on:
| Component | Props | entrepta | lib / npm | "use client" |
|---|---|---|---|---|
RateCard |
rate: Rate |
Badge | money, types, next/image |
no — server-renderable |
RateList |
state: RateListState |
Skeleton | types; composes RateCard + CarrierNotices |
interactive retry |
CarrierNotices |
carriers: UnavailableCarrier[] |
— | types |
local open state |
ShippingForm |
onSubmit, pending? |
Input, Button | schemas; react-hook-form, zod |
React Hook Form |
None needs a provider.
Browser
→ Zod validation (client, UX)
→ POST /api/rates
→ reads SHIPPO_API_KEY server-side
→ Zod validation (server, correctness)
→ POST api.goshippo.com/shipments/
→ normalize ShippoRate[] → Rate[] (pure, sorted)
→ render
Verified against a live test-mode call. A saved response lives in
lib/shippo/__fixtures__/shipment-response.json; the tests run against it and
never touch the network. Every item below breaks a naive render, which is why
the normalization layer exists:
amountis a string ("88.03"). Sorting as a string gives the wrong order. It is parsed to integer cents.servicelevel.display_nameisnullfor USPS. UPS has"UPS® Ground"; USPS hasnulland only aname. Fallback isdisplay_name ?? name.arrives_byis oftennull. It survives asnull, not""or a guess.duration_termsis sometimes an empty string, not null. Normalized tonull.attributescarriesCHEAPEST,FASTEST,BESTVALUE. Shippo computes these; the UI renders them and never recalculates.- The top-level
messagesarray holds carrier failures whilestatusis stillSUCCESS. In the sample call, 17 messages — carriers out of service area, accounts that do not support the requested options, DHL rejecting domestic US. Graceful degradation, surfaced as a collapsible "N carriers unavailable" disclosure rather than swallowed.
Point 6 is the highest-signal detail: most integrations ignore that array.
Each of these is a deliberate choice, recorded so it reads as judgment.
- No
shippoSDK — directfetch. The SDK is heavier than this needs, and a thin hand-written client (lib/shippo/client.ts) makes the typed error handling explicit and easy to read. - A server route holds the API key.
app/api/rates/route.tscalls Shippo server-side; the key never reaches the browser. The route returns only the normalized shape, never Shippo's raw response — a security decision as much as a performance one. - Money is integer cents, never a float.
"88.03"becomes8803. Parsing and formatting live inlib/money.ts, and the string is parsed digit by digit so float representation error can never round a price wrong. - Normalization is a pure function.
lib/shippo/normalize.tsmaps a Shippo response toRateQuoteResultwith no I/O, so it is tested against the fixture with zero mocking. Sorting (price ascending) happens here, once, on the server. - Typed, classified errors.
ShippoErrorcarries aretriableflag; HTTP statuses are classified (401/403 config, 400/422 input, 429/5xx transient). There is no automatic retry in v1 — the user retries by clicking. That is the first thing to add for production. - One Zod schema, both sides.
lib/schemas.tsvalidates on the client (UX) and in the route handler (correctness, because a public endpoint cannot trust its input).
One deliberate UI divergence: a rate card is a semantic <li>, not an entrepta
Card. The results are a real list (accessibility), and Card renders a <div>
whose padded vertical layout fights the compact row. It still uses entrepta
Badge for the CHEAPEST/FASTEST/BESTVALUE markers.
Numbers, not adjectives — measured on the production build, gzipped.
Payload. The route returns the normalized RateQuoteResult, not Shippo's raw
response: ~13.7 kB → ~6.0 kB, about 56% smaller, for one shipment. Less over
the wire, and nothing internal leaked to the client.
Client JS, first load (modern browsers; the noModule legacy-polyfill chunk
is excluded because modern browsers never download it):
| Build | First-load JS (gz) |
|---|---|
Turbopack (next build, the deployed path) |
~236 kB |
webpack (next build --webpack) |
~172 kB |
React 19 + the Next 16 App Router ship a hydration floor of ~124 kB gz (react-dom + the React/Next client runtime) before any app code runs, and the main page must hydrate because it is a live form. The one controllable contributor is the ~45 kB app chunk (Zod + React Hook Form + the components); the rest is fixed framework cost.
What keeps it fast anyway:
- Server Components by default. Only the interactive island and the
components that need it carry
"use client"; the shell and heading are server-rendered. - Zero layout shift by construction. The skeleton row shares the exact class
of a real rate card, so loading → results shifts nothing. Carrier logos use
next/imagewith explicit dimensions; the results column reserves its height. - Fonts via
next/font(Newsreader, JetBrains Mono, Inter),display: swap, latin subset, self-hosted — no render-blocking CDN import.
Not yet measured: Lighthouse and the Core Web Vitals (LCP, CLS, INP) need a run against the deployed build in a real browser. Record them here after deploying; the bundle numbers above are measured, these are pending.
Stated plainly, because undocumented gaps read as oversights:
- No retry logic. Errors are classified retriable or not, but retrying is a click, not automatic. First thing to add for production.
- US domestic only. No international shipping or customs.
- Single parcel. The API accepts an array; we send one item.
- Rate shopping only. No label purchase, tracking, auth, or persistence — all deliberate non-goals.