Build a multi-carrier shipping integration with V0 that calculates real-time rates from UPS, FedEx, and USPS, generates labels, and tracks shipments using EasyPost API. You'll create rate comparison cards, address validation, webhook-based tracking updates, and a shipment management dashboard — all in about 1-2 hours.
What you're building
Every e-commerce app needs shipping — customers expect to see delivery options with prices and timeframes at checkout. Without a shipping integration, you are stuck with flat-rate guesses that either overcharge customers or eat into your margins. Real-time carrier rates solve this.
V0 generates the Next.js API routes for EasyPost integration, the rate comparison UI, and the tracking dashboard from prompts. Supabase stores shipments, cached rates, and validated addresses via the Connect panel. EasyPost aggregates UPS, FedEx, USPS, and DHL into a single API, so you don't need separate accounts with each carrier.
The architecture uses API routes at app/api/shipping/ for rate fetching, label purchasing, and webhook handling. Server Actions handle address validation. The webhook endpoint at app/api/webhooks/shipping/route.ts receives tracking updates from EasyPost with HMAC-SHA256 verification, identical to the Stripe webhook pattern.
Final result
A complete shipping integration with multi-carrier rate comparison, address validation, label generation, webhook-based tracking, and a shipment management dashboard.
Tech stack
Prerequisites
- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- An EasyPost account (free tier includes test mode with all carriers)
- Your EasyPost test API key from the EasyPost dashboard
- An e-commerce app or order system to integrate shipping into
Build steps
Set up the database schema for shipments and rates
Open V0 and create a new project. Use the Connect panel to add Supabase. Prompt V0 to create the shipments, shipping_rates, and addresses tables for storing shipment data, cached rate quotes, and validated addresses.
1// Paste this prompt into V0's AI chat:2// Create a Supabase schema for a shipping integration:3// 1. shipments table: id (uuid PK), order_id (uuid), carrier (text), service_level (text), tracking_number (text), label_url (text), rate_cents (int), status (text DEFAULT 'pending' — 'pending', 'in_transit', 'delivered', 'exception'), estimated_delivery (date), created_at (timestamptz)4// 2. shipping_rates table: id (uuid PK), shipment_id (uuid FK), carrier (text), service (text), rate_cents (int), transit_days (int), fetched_at (timestamptz) for caching rate quotes5// 3. addresses table: id (uuid PK), user_id (uuid FK), street (text), city (text), state (text), zip (text), country (text DEFAULT 'US'), is_validated (boolean DEFAULT false)6// Add RLS policies so authenticated users can only see their own shipments.7// Generate the SQL migration.Pro tip: Use V0's Vars tab to store EASYPOST_API_KEY as a server-only secret (no NEXT_PUBLIC_ prefix). EasyPost API keys should never be exposed to the browser.
Expected result: Three tables created in Supabase with proper foreign keys, RLS policies, and the EASYPOST_API_KEY stored in V0's Vars tab.
Build the shipping rates API route
Create an API route that accepts origin and destination addresses plus parcel dimensions, calls EasyPost for real-time rates from all carriers, caches the results in Supabase, and returns them sorted by price.
1import { NextRequest, NextResponse } from 'next/server'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.SUPABASE_SERVICE_ROLE_KEY!7)89export async function POST(req: NextRequest) {10 const { from_address, to_address, parcel } = await req.json()1112 const shipmentRes = await fetch('https://api.easypost.com/v2/shipments', {13 method: 'POST',14 headers: {15 Authorization: `Bearer ${process.env.EASYPOST_API_KEY}`,16 'Content-Type': 'application/json',17 },18 body: JSON.stringify({19 shipment: {20 from_address,21 to_address,22 parcel,23 },24 }),25 })2627 const shipment = await shipmentRes.json()2829 if (shipment.error) {30 return NextResponse.json({ error: shipment.error.message }, { status: 400 })31 }3233 const rates = shipment.rates34 .map((rate: any) => ({35 id: rate.id,36 carrier: rate.carrier,37 service: rate.service,38 rate_cents: Math.round(parseFloat(rate.rate) * 100),39 transit_days: rate.est_delivery_days,40 currency: rate.currency,41 }))42 .sort((a: any, b: any) => a.rate_cents - b.rate_cents)4344 return NextResponse.json({45 shipment_id: shipment.id,46 rates,47 })48}Expected result: POST to /api/shipping/rates with addresses and parcel dimensions returns a sorted array of carrier rates with price, transit time, and service level.
Create the rate comparison UI and address form
Build a client component with an address form and rate comparison cards. Users enter origin and destination, see real-time rates from all carriers, and select their preferred option with a RadioGroup.
1'use client'23import { useState } from 'react'4import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'5import { Input } from '@/components/ui/input'6import { Button } from '@/components/ui/button'7import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'8import { Badge } from '@/components/ui/badge'9import { Label } from '@/components/ui/label'1011type ShippingRate = {12 id: string13 carrier: string14 service: string15 rate_cents: number16 transit_days: number17}1819export function ShippingRateSelector() {20 const [rates, setRates] = useState<ShippingRate[]>([])21 const [selected, setSelected] = useState('')22 const [loading, setLoading] = useState(false)2324 async function fetchRates(formData: FormData) {25 setLoading(true)26 const res = await fetch('/api/shipping/rates', {27 method: 'POST',28 headers: { 'Content-Type': 'application/json' },29 body: JSON.stringify({30 from_address: {31 street1: formData.get('from_street'),32 city: formData.get('from_city'),33 state: formData.get('from_state'),34 zip: formData.get('from_zip'),35 country: 'US',36 },37 to_address: {38 street1: formData.get('to_street'),39 city: formData.get('to_city'),40 state: formData.get('to_state'),41 zip: formData.get('to_zip'),42 country: 'US',43 },44 parcel: { length: 10, width: 8, height: 4, weight: 16 },45 }),46 })47 const data = await res.json()48 setRates(data.rates ?? [])49 setLoading(false)50 }5152 return (53 <div className="space-y-6">54 <form action={fetchRates} className="grid grid-cols-2 gap-4">55 <div className="space-y-2">56 <h3 className="font-semibold">From Address</h3>57 <Input name="from_street" placeholder="Street" required />58 <Input name="from_city" placeholder="City" required />59 <Input name="from_state" placeholder="State" required />60 <Input name="from_zip" placeholder="ZIP" required />61 </div>62 <div className="space-y-2">63 <h3 className="font-semibold">To Address</h3>64 <Input name="to_street" placeholder="Street" required />65 <Input name="to_city" placeholder="City" required />66 <Input name="to_state" placeholder="State" required />67 <Input name="to_zip" placeholder="ZIP" required />68 </div>69 <Button type="submit" disabled={loading} className="col-span-2">70 {loading ? 'Fetching rates...' : 'Get Shipping Rates'}71 </Button>72 </form>73 <RadioGroup value={selected} onValueChange={setSelected}>74 <div className="grid gap-3">75 {rates.map((rate) => (76 <Label key={rate.id} htmlFor={rate.id} className="cursor-pointer">77 <Card className={selected === rate.id ? 'border-primary' : ''}>78 <CardContent className="flex items-center justify-between p-4">79 <div className="flex items-center gap-3">80 <RadioGroupItem value={rate.id} id={rate.id} />81 <div>82 <p className="font-medium">{rate.carrier} — {rate.service}</p>83 <p className="text-sm text-muted-foreground">84 {rate.transit_days} business days85 </p>86 </div>87 </div>88 <Badge variant="secondary">89 ${(rate.rate_cents / 100).toFixed(2)}90 </Badge>91 </CardContent>92 </Card>93 </Label>94 ))}95 </div>96 </RadioGroup>97 </div>98 )99}Pro tip: Use Design Mode (Option+D) to visually adjust the rate Card layout — add carrier logos, adjust spacing between rate options, and color-code the cheapest option as green.
Expected result: Users fill in origin and destination addresses, click Get Shipping Rates, and see a list of carrier options as selectable Card components sorted by price with transit time estimates.
Build the webhook handler for tracking updates
Create a webhook endpoint that receives tracking updates from EasyPost. This uses request.text() for raw body to verify the HMAC-SHA256 signature, the same pattern as Stripe webhooks.
1import { NextRequest, NextResponse } from 'next/server'2import { createHmac } from 'crypto'3import { createClient } from '@supabase/supabase-js'45const supabase = createClient(6 process.env.NEXT_PUBLIC_SUPABASE_URL!,7 process.env.SUPABASE_SERVICE_ROLE_KEY!8)910export async function POST(req: NextRequest) {11 const rawBody = await req.text()12 const signature = req.headers.get('x-hmac-signature')1314 if (!signature || !process.env.EASYPOST_WEBHOOK_SECRET) {15 return NextResponse.json({ error: 'Missing signature' }, { status: 401 })16 }1718 const expectedSig = createHmac('sha256', process.env.EASYPOST_WEBHOOK_SECRET)19 .update(rawBody)20 .digest('hex')2122 if (signature !== expectedSig) {23 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })24 }2526 const event = JSON.parse(rawBody)27 const tracker = event.result2829 if (event.description === 'tracker.updated') {30 const statusMap: Record<string, string> = {31 pre_transit: 'pending',32 in_transit: 'in_transit',33 out_for_delivery: 'in_transit',34 delivered: 'delivered',35 failure: 'exception',36 }3738 await supabase39 .from('shipments')40 .update({41 status: statusMap[tracker.status] ?? 'in_transit',42 estimated_delivery: tracker.est_delivery_date,43 })44 .eq('tracking_number', tracker.tracking_code)45 }4647 return NextResponse.json({ received: true })48}Expected result: Tracking updates from EasyPost are verified and automatically update shipment status in Supabase. The webhook URL is set after deploying to production.
Create the shipment tracking dashboard
Build a shipment management page that shows all shipments with their current status, tracking numbers, and estimated delivery dates. Use a Table with sortable columns and Badge status indicators.
1// Paste this prompt into V0's AI chat:2// Build a shipment tracking dashboard at app/shipping/page.tsx.3// Requirements:4// - Server Component that fetches all shipments from Supabase ordered by created_at desc5// - Display in a shadcn/ui Table with columns: Order ID, Carrier, Tracking Number, Status, Estimated Delivery, Rate6// - Status column uses Badge with colors: pending=gray, in_transit=blue, delivered=green, exception=red7// - Each tracking number is a clickable link to the carrier's tracking page8// - Add a filter row at the top with Select for status filtering and Input for search by tracking number9// - Include a Card at the top showing summary stats: total shipments, in transit, delivered today, exceptions10// - Add a Dialog that shows full shipment details when clicking a row, including label download link11// - Use Separator between the summary cards and the main tableExpected result: A dashboard showing all shipments in a Table with color-coded status Badges, summary cards at the top, and a detail Dialog for each shipment with label download.
Complete code
1import { NextRequest, NextResponse } from 'next/server'23export async function POST(req: NextRequest) {4 const { from_address, to_address, parcel } = await req.json()56 const response = await fetch('https://api.easypost.com/v2/shipments', {7 method: 'POST',8 headers: {9 Authorization: `Bearer ${process.env.EASYPOST_API_KEY}`,10 'Content-Type': 'application/json',11 },12 body: JSON.stringify({13 shipment: { from_address, to_address, parcel },14 }),15 })1617 const shipment = await response.json()1819 if (shipment.error) {20 return NextResponse.json(21 { error: shipment.error.message },22 { status: 400 }23 )24 }2526 const rates = shipment.rates27 .map((rate: any) => ({28 id: rate.id,29 carrier: rate.carrier,30 service: rate.service,31 rate_cents: Math.round(parseFloat(rate.rate) * 100),32 transit_days: rate.est_delivery_days,33 currency: rate.currency,34 }))35 .sort(36 (a: { rate_cents: number }, b: { rate_cents: number }) =>37 a.rate_cents - b.rate_cents38 )3940 return NextResponse.json({41 shipment_id: shipment.id,42 rates,43 })44}Customization ideas
Add address autocomplete
Integrate Google Places API or USPS Address Validation to autocomplete addresses as users type, reducing errors and improving delivery success rates.
Add batch label generation
Create a bulk shipment endpoint that accepts multiple orders and generates labels for all of them in parallel, useful for daily shipping runs.
Add shipping insurance options
Display carrier insurance options alongside rates and let customers add package insurance at checkout using EasyPost's insurance endpoint.
Add return label generation
Create a self-service returns portal where customers can generate prepaid return labels linked to their original order.
Common pitfalls
Pitfall: Using request.json() instead of request.text() in the webhook handler
How to avoid: Always use request.text() first to get the raw body, verify the signature, then parse with JSON.parse(rawBody) only after verification passes.
Pitfall: Exposing the EasyPost API key with NEXT_PUBLIC_ prefix
How to avoid: Store EASYPOST_API_KEY in V0's Vars tab without any prefix. Call EasyPost only from API routes (app/api/) which run server-side on Vercel.
Pitfall: Not caching rate quotes before displaying them
How to avoid: Cache rate responses in the shipping_rates table with a fetched_at timestamp. Return cached rates if they are less than 15 minutes old; otherwise fetch fresh rates.
Pitfall: Setting up the webhook URL before deploying to production
How to avoid: First publish to production via Share > Publish. Then copy your production URL and register it as the webhook endpoint in the EasyPost dashboard.
Best practices
- Store EASYPOST_API_KEY and EASYPOST_WEBHOOK_SECRET in V0's Vars tab as server-only secrets — never prefix with NEXT_PUBLIC_
- Cache rate quotes in Supabase for 15 minutes to avoid redundant API calls and speed up repeated rate lookups
- Use request.text() in webhook handlers for HMAC signature verification — the same pattern used for Stripe webhooks
- Always validate addresses before creating shipments to avoid carrier rejection and redelivery fees
- Display rates sorted by price with transit time so customers can make informed cost-vs-speed decisions
- Use Design Mode (Option+D) to visually polish rate comparison Cards with carrier logos and color-coded pricing
- Register webhook URLs only after deploying to production — preview URLs are temporary and will break
- Store rates in cents (integers) not dollars (floats) to avoid floating-point rounding errors in price calculations
AI prompts to try
Copy these prompts to build this project faster.
I'm building a shipping integration with Next.js App Router and EasyPost API. I need: 1) An API route that fetches real-time shipping rates from multiple carriers, 2) A webhook handler for tracking updates with HMAC-SHA256 verification, 3) A label generation endpoint, 4) A Supabase schema for shipments and rate caching. Help me design the architecture and handle edge cases like carrier timeouts and rate caching.
Create a webhook handler at app/api/webhooks/shipping/route.ts for EasyPost tracking updates. The handler must: 1) Read the raw body using request.text(), 2) Verify the HMAC-SHA256 signature from the x-hmac-signature header against EASYPOST_WEBHOOK_SECRET, 3) Parse the event and update the shipment status in Supabase based on the tracker.status field, 4) Map EasyPost statuses (pre_transit, in_transit, out_for_delivery, delivered, failure) to our status enum. Return 200 quickly.
Frequently asked questions
Which shipping carriers does EasyPost support?
EasyPost supports 100+ carriers including UPS, FedEx, USPS, DHL, Canada Post, and regional carriers. The free test mode gives you access to all carriers with simulated rates. In production, you connect your own carrier accounts or use EasyPost's negotiated rates.
Can I use this shipping integration with my existing e-commerce app?
Yes. The shipping rates API and label generation endpoints are standalone API routes. Call them from any frontend — your existing app makes a POST to /api/shipping/rates with addresses, and gets back carrier rates. The webhook handler updates tracking status automatically.
Do I need a paid EasyPost account?
No. EasyPost's test mode is free and simulates all carrier rates, label generation, and tracking updates. You only pay when you switch to production mode and purchase real labels. Test mode is sufficient for building and testing the entire integration.
How do webhook tracking updates work?
After purchasing a label through EasyPost, they automatically track the package. When the status changes (picked up, in transit, delivered), EasyPost sends a POST to your webhook URL with the updated tracker data. Your handler verifies the signature and updates the shipment status in Supabase.
What V0 plan do I need?
V0 Free tier works for this project. The shipping integration uses standard API routes, Server Components, and shadcn/ui components. Design Mode adjustments for polishing the rate comparison cards are also free.
Can RapidDev help build a custom shipping integration?
Yes. RapidDev has built 600+ apps including e-commerce platforms with multi-carrier shipping, real-time tracking, and custom fulfillment workflows. Book a free consultation to discuss your shipping requirements and carrier needs.
How do I handle international shipping?
EasyPost supports international shipments. Add customs_info to the shipment object with item descriptions, HS codes, and declared values. The API returns international rates with duty estimates. Add country Select to the address form for international destinations.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation