ShipLocal build series · Part 5
Full-Stack Previews Are Here — Multiple Tunnel Targets per Project
One project, two URLs, no staging deploy for the API.
Article 5 in the ShipLocal build series — what we just shipped and why.
For most of ShipLocal's first release, the story was simple:
shiplocal 3000
You got one public URL. Your client opened it. You collected feedback on the live preview. That loop worked well for marketing sites, dashboards, and single-process apps.
Then real projects showed up — the ones with a frontend on :3000 and an API on :4000. And the same broken workflow appeared again:
"The page loads, but login doesn't work."
Because the browser on the preview URL cannot reach localhost:4000 on your laptop. That address only exists on your machine.
We set out to fix that without asking every team to deploy staging just to demo full-stack work. v0.2 is the first piece of that answer.
What we shipped
You can now group multiple tunnels under one project with predictable URLs:
# Terminal 1 — frontend
shiplocal 3000 --project myapp
# Terminal 2 — API
shiplocal 4000 --project myapp --name api
That gives you:
https://myapp.shiplocal.cloud → localhost:3000
https://myapp-api.shiplocal.cloud → localhost:4000
Same project. Two processes. Two public URLs. No random animal names to juggle.
Along with coordinated URLs, v0.2 includes:
| Piece | What it does |
|---|---|
| Project slugs + target names | Stable naming (web, api, …) instead of one tunnel = one random subdomain |
| Sibling CORS | Browser requests from the frontend preview to the API preview get CORS headers at the proxy |
| Cookie domain rewrite | Set-Cookie: Domain=localhost from your API is rewritten for the preview hostname |
--rewrite-env | CLI helper that suggests or applies .env updates when tunnel URLs are created |
| Reconnect reuse | Restarting the CLI reuses the same tunnel row and public URL instead of spawning duplicates |
This is Core work — it helps self-hosters and Cloud users alike.
Why this matters
The localhost trap is really a graph problem
A full-stack app is not one port. It is a small graph:
Browser → frontend (:3000) → API (:4000) → database
A single tunnel only bridges one edge of that graph. The browser still tries to call http://localhost:4000 because that is what your .env says.
Traditional fixes:
- Deploy staging — works, but slow and expensive for a design review
- Dev-server proxy — works if everything is relative and your bundler proxies
/api - Second tunnel + manual env swap — works, but easy to forget and painful for clients
Option 3 was always possible in ShipLocal (run two CLIs, get two random URLs). What was missing was coordination: names that make sense, CORS that works across sibling previews, and tooling that nudges your env vars in the right direction.
Agencies live in the two-URL world
Freelancers and agencies demo whole products, not just landing pages. The client needs to click through auth, forms, and dashboards — flows that hit a real backend.
With multi-target tunnels:
- You share one frontend URL with the client (the one in the feedback overlay)
- You point
NEXT_PUBLIC_API_URL(or equivalent) at the API tunnel URL - The preview behaves much closer to production — without a deploy
That is the workflow we wanted from day one. v0.1 proved the tunnel loop. v0.2 makes it credible for Next.js + API, Vite + Fastify, and similar stacks.
How to use it
Basic two-process setup
shiplocal login
shiplocal 3000 --project portfolio
shiplocal 4000 --project portfolio --name api
Set your frontend env:
NEXT_PUBLIC_API_URL=https://portfolio-api.shiplocal.cloud
Or let the CLI suggest changes:
shiplocal 4000 --project portfolio --name api --rewrite-env
Naming rules
--projectsets the project slug (creates the project if it does not exist)--namesets the target within the project (default:web)- The web target gets
https://{slug}.shiplocal.cloud - Other targets get
https://{slug}-{name}.shiplocal.cloud
Examples:
| Command | Public URL |
|---|---|
shiplocal 3000 --project myapp | https://myapp.shiplocal.cloud |
shiplocal 4000 --project myapp --name api | https://myapp-api.shiplocal.cloud |
shiplocal 3002 --project myapp --name admin | https://myapp-admin.shiplocal.cloud |
Legacy mode still works
If you run shiplocal 3000 with no --project, you get the same random subdomain behavior as before (bright-panda.shiplocal.cloud). Nothing breaks for single-port workflows.
Multi-target is opt-in. That was intentional — we did not want to force every user to think about project slugs on day one.
What this does not solve (yet)
We are honest about the boundaries:
- Path-based routing — one URL with
/api/*→ backend is still on the roadmap (issue #2 in our backlog). Some teams will prefer that over two links. - Third-party APIs — if your app calls Stripe, Paystack, or another external service, their CORS policy still applies. We only fix sibling tunnels in the same ShipLocal project.
- WebSocket upgrades for user apps — Socket.io and similar through the tunnel are next (v0.2 roadmap).
- Zero-config — you still choose ports and target names. Auto-detecting running services is future work.
Multi-target is the foundation. Path routing and zero-config build on top of it.
What changes for you today
If you already use ShipLocal for client previews:
- Try
--projecton your next full-stack demo — especially when auth or data fetching is involved - Use
--rewrite-envonce to see what the CLI would change in your.env - Clean up old tunnels in the dashboard if your default project accumulated random legacy URLs from v0.1 testing
If you self-host, see the new section in docs/self-hosting.md.
What is next in the series
Shipping coordinated URLs was the product milestone. The engineering story — flat subdomains vs nested ones, CORS at the proxy layer, reconnect semantics, legacy migration — is messier than the CLI output suggests.
The next article walks through what was harder than we expected while building this feature.
Try it
npm install -g shiplocal
shiplocal login
shiplocal 3000 --project myapp
Open app.shiplocal.cloud to see all targets under your project.
ShipLocal is open source: github.com/ship-local/shiplocal.