Skip to content

Repository files navigation

shipkit

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.


Run it

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:3000

The key is read server-side only and never reaches the browser.


Architecture

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.

Request flow

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

What the Shippo API actually returns

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:

  1. amount is a string ("88.03"). Sorting as a string gives the wrong order. It is parsed to integer cents.
  2. servicelevel.display_name is null for USPS. UPS has "UPS® Ground"; USPS has null and only a name. Fallback is display_name ?? name.
  3. arrives_by is often null. It survives as null, not "" or a guess.
  4. duration_terms is sometimes an empty string, not null. Normalized to null.
  5. attributes carries CHEAPEST, FASTEST, BESTVALUE. Shippo computes these; the UI renders them and never recalculates.
  6. The top-level messages array holds carrier failures while status is still SUCCESS. 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.


Trade-offs

Each of these is a deliberate choice, recorded so it reads as judgment.

  • No shippo SDK — direct fetch. 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.ts calls 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" becomes 8803. Parsing and formatting live in lib/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.ts maps a Shippo response to RateQuoteResult with 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. ShippoError carries a retriable flag; 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.ts validates 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.


Performance

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/image with 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.


Known gaps

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.

License

MIT

About

Shipping rate comparison built on the Shippo API. Next.js + TypeScript.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages