Frontends. Vercel, Netlify and a frontend in another repository.

Keep your frontend previews where they are. Karts gives each branch its own backend, with its own migrated and seeded Postgres, at https://<env>.karts.kartikey.fyi, and your preview calls it. If the frontend is itself a Karts project in another repository, use links: instead.

Status. The CLI output and allow_origins below are built and tested. The GitHub Actions workflow that runs this per pull request is tested against fakes only. It has not yet been run against real Vercel or Netlify projects, so it is not published here yet.

1. A same-origin rewrite (recommended)

The frontend host proxies /api/* to the backend. The browser sees one origin: no CORS, and a cookie the backend sets is first-party on the preview's host.

Next.js on Vercel. Next reads rewrites() at build time, so set KARTS_BACKEND_URL as a branch-scoped Preview variable before the build:

// next.config.mjs: /api/* on the preview goes to the branch's Karts backend
export default {
  async rewrites() {
    const url = process.env.KARTS_BACKEND_URL; // the branch's Karts URL
    return url ? [{ source: "/api/:path*", destination: `${url}/api/:path*` }] : [];
  },
};

Netlify. A _redirects rule, written into the built site at deploy time:

/api/*  https://feature-x-1a2b.karts.kartikey.fyi/api/:splat  200!

A plain vercel.json cannot read a variable, so it only works for one fixed backend.

2. Cross-origin calls with allow_origins

List the frontend origins in the backend's karts.yml. Karts validates them and passes them to your app as KARTS_ALLOWED_ORIGINS, canonical and comma-separated. Your app answers CORS: Karts' proxy passes CORS headers through and adds none.

allow_origins:
  - https://shop-*-myteam.vercel.app    # Vercel previews: project shop, team myteam
  - https://*--mysite.netlify.app       # Netlify deploy previews of site mysite
  - http://localhost:3000               # a local frontend
# or: allow_origins: [https://shop-*-myteam.vercel.app, http://localhost:3000]
  • Each pattern is scheme://host[:port], lower case, with nothing after the host. https for every host; http only for localhost, hosts ending in .localhost (such as http://*.shop-web.localhost:7400, every local branch of shop-web) and 127.0.0.1.
  • One * at most, inside the leftmost label. It matches one or more letters, digits and dashes, never a dot.
  • Refused: *, null, IP addresses other than 127.0.0.1, a whole shared-hosting domain such as https://*.vercel.app or https://*.netlify.app (anyone can deploy there), a wildcard right under a public suffix such as co.uk, and any wildcard under karts.kartikey.fyi. At most 20 patterns. A bad pattern fails karts up with the line and the fix.
  • KARTS_ALLOWED_ORIGINS holds the patterns as you wrote them. A CORS library that compares origins exactly, such as Express cors or NestJS enableCors given a list, never matches a pattern with * and sends no Access-Control-Allow-Origin. With a wildcard, pass the library a function that applies the rules above, or list exact origins. With karts up --local, Karts also appends the exact origin of each local environment a wildcard matches at that up, so an exact-match library works there. A front end started later is added at the API's next karts up --local.
  • Answer with the request's Origin in Access-Control-Allow-Origin, plus Vary: Origin. Never *, and never Access-Control-Allow-Credentials: send a bearer token in Authorization instead.
  • CORS decides what a browser page may read. It is not authentication: curl ignores it. Karts environments are public and hold seed data only.

Cookies

  • A cookie your backend sets on a cross-origin call is a third-party cookie. Safari and Firefox block those by default, so do not build on it. Use the rewrite, or bearer tokens.
  • Never set Domain=karts.kartikey.fyi on a cookie. Every environment shares that domain for now, so such a cookie would reach all of them. Host-only cookies, with no Domain, are the safe kind.

3. The backend URL in CI

In CI, pass --branch and --workspace explicitly, and --commit to up. A fresh clone has its own random workspace, so a later karts url or karts down would not find the environment otherwise.

URL=$(karts up --commit "$SHA" --branch "$BRANCH" --workspace "gh-pr-$PR" --output url)
karts url --branch "$BRANCH" --workspace "gh-pr-$PR"          # the same URL again, later
karts down --branch "$BRANCH" --workspace "gh-pr-$PR" --output json

karts up --output json prints one JSON object on stdout and nothing else:

{
  "schema_version": 1,
  "env": "ev_…",
  "name": "feature-x-1a2b",
  "key": {"project": "pr_…", "branch": "feature/x", "workspace": "gh-pr-42", "slot": 1},
  "url": "https://feature-x-1a2b.karts.kartikey.fyi",
  "generation": 3,
  "state": "ready",
  "created": false,
  "project": "pr_…",
  "new_migrations": ["0011_add_refunds.sql"],
  "branch_upload": "up_…",
  "base_upload": "up_…",
  "base_commit": "…"
}
CommandPrints on successExit
karts up --output urlthe URL and a newline, once the environment is ready0; else 1, reason on stderr
karts url … --output jsonschema_version, env, name, url, state, key0; 1 when none serves
karts list --output jsonevery environment of the project0
karts down … --output jsonstate: "gone"0
  • created is true when this call created the environment, so its URL is new. The name, and so the URL, stays the same for every later up of the same branch and workspace until the environment is taken down or reaped.
  • A failure prints {"schema_version": 1, "error": {"code", "message", "details"}} on stdout and the message on stderr, and exits 1. Usage errors exit 2 and print no JSON.
  • Fields are only ever added within a schema version. Pin the CLI version in CI.
  • For CI, issue a CI token on the Karts dashboard and set it as KARTS_TOKEN. A project-scoped token works in that one project only. Tokens last 90 days unless you choose otherwise.

4. One backend per pull request

The workflow we are testing keeps one Karts backend per open pull request, and nothing for closed ones:

  • On every pull-request event, and hourly, it asks GitHub what the pull request is now. Open, from this repository, into the default branch: karts up for its current head and set the preview's KARTS_BACKEND_URL. Anything else: karts down and remove the variable.
  • It runs only the default branch's copy of the workflow, and treats the pull request's code as data: nothing from the pull request runs on the CI runner, only inside the Karts VM and on Vercel's own builders. Pull requests from forks are not supported.
  • Karts' idle reaper is the last backstop for anything left behind.