← All posts

Workspace-aware links: a URL that means the same thing for everyone

Every dashboard URL was /dashboard/… and the workspace was a cookie, so a mailed deep link opened whichever workspace the reader last had active. The fix is a /w/<slug>/… entry route, and the plan after it is /{account}/{workspace}.

Vocion Teamvocion-v2.74.0

The daily team report mails you a link to your inbox. Someone opened theirs and landed in a different workspace.

Nothing was broken in the sense of throwing an error. Every dashboard URL was /dashboard/…, and which workspace /dashboard/inbox showed came from a cookie — vocion_active_project, resolved into projectId on every session read. The link was correct. It just did not carry the one fact it was about.

That is a URL that means something different for each reader, which fails the plainest test there is: opening a link should show you the thing the link is about.

The shape of the bug

The report built its links out of an origin and a path:

// before
links: {
  inbox:      `${base}/dashboard/inbox`,
  teamReport: `${base}/dashboard/team-report`,
  briefings:  `${base}/dashboard/briefings`,
}

There is no workspace anywhere in that. The cookie supplied it, and a cookie belongs to the reader, not to the link.

The same cookie had a second writer. The sidebar workspace switcher set it client-side after calling the API, because the API response cannot write cookies — so a switch made one way could be undone by a switch made the other way, and the two places had to agree on the name, the path and the lifetime by convention.

The entry route

Phase one adds one route: /w/<slug>/<path…>. It is a route handler, not a page, and it is a redirect that sets the cookie server-side.

export async function GET(request: NextRequest, { params }: Params): Promise<NextResponse> {
  const { locale, workspace, path } = await params;
  const origin = publicOrigin(request);

  const session = await auth();
  if (!session?.user?.id) {
    const signIn = new URL(`${prefix}/sign-in`, origin);
    signIn.searchParams.set('callbackUrl', new URL(request.nextUrl.pathname + request.nextUrl.search, origin).toString());
    return NextResponse.redirect(signIn, 302);
  }

  const project = await resolveProjectForUser(session.user.id, { slug: workspace });
  if (!project) {
    return new NextResponse('No such workspace', { status: 404 });
  }

  const target = new URL(workspaceRedirectPath({ segments: path, search: request.nextUrl.search, locale }), origin);
  const response = NextResponse.redirect(target, 302);
  response.cookies.set(ACTIVE_PROJECT_COOKIE, project.id, ACTIVE_PROJECT_COOKIE_OPTIONS);
  return response;
}

Four properties are load-bearing.

Membership is checked before the cookie is set. resolveProjectForUser scopes to the user's own account and matches the slug case-insensitively. An unknown slug and a workspace the reader is not a member of both return 404 — indistinguishable on purpose, so the reader learns nothing about other tenants' slugs.

Signed-out works. The middleware already treats the segment as protected and sends the reader to sign-in with a callbackUrl pointing back at the /w/ URL, so signing in lands in the right workspace rather than the last one. The route's own signed-out branch is the belt to that brace.

The cookie has one definition. It was a private constant inside the auth library; it is now libs/activeProject.ts, imported by both writers, because two callers that must agree on a cookie's name, path and lifetime should not each have their own copy of those three facts.

Redirects are built from the public origin, not the request URL. Behind a reverse proxy the server binds an internal address, and a redirect built from request.url sends the browser somewhere unreachable. publicOrigin() prefers the configured app URL, then the forwarded host and protocol, then the request origin.

Path construction is deliberately boring. Segments are URL-encoded individually, so nothing a caller puts in the path can escape the app's own origin, and repeated slashes are collapsed so /dashboard//inbox cannot become a protocol-relative //host path.

One mechanism for a switch

The sidebar switcher no longer writes the cookie itself. It navigates through the new route:

const switchTo = (project: Project) => {
  setSwitching(project.id);
  const prefix = locale !== routing.defaultLocale ? `/${locale}` : '';
  window.location.assign(`${prefix}${workspaceUrl(project.slug, `${pathname}${window.location.search}`)}`);
};

Same path a mailed link takes. The address bar, a shared URL and the sidebar can no longer disagree about which workspace is active.

The workspace switcher in the sidebar. Switching is now a navigation through /w/<slug>/…, which is also what the sidebar's workspace chip copies — the link you hand someone opens the workspace you are in.

Everything that mails a link was rewired: the daily team report, and every ask, which now carries a read-only absolute url in the API. Things that were deliberately left alone are listed in the change too — billing return URLs happen in the same browser session where the cookie is already set, and in-app hrefs are relative links rendered inside the already-active workspace.

What phase one does not do

The address bar still ends at /dashboard/… after the redirect. A URL copied from the bar is still ambiguous. That is the next phase, and it is written down as a design rather than shipped:

The target is Vercel's model — vercel.com/{team}/{project}/analytics — where the account and the workspace are path segments and the page is the same for everyone who holds the link.

The plan moves the authenticated routes under [account]/[workspace], has the middleware parse and authorise the pair before any page code runs, and forwards the result as request headers. A compatibility shim reads those headers first, the cookie second, and first-project last — so the services that take an orgId never learn the URL changed. The cookie gets demoted to "last active" and stops deciding what a page shows.

Two details in that plan are the kind you only find by writing it down. Locale middleware has to run after the pair is parsed, or /fr/acme/x reads as account fr. And slug renames need a redirect table, because the whole point of the change is that these URLs get shared.

The token-authenticated surfaces are unaffected throughout. They scope by orgId from the token, never from a path.

Where this lives in the repo

#336 is merged to main and shipped, and it rode the same 2026-09-15 release as the rest of this batch.

Not verified: the mail render test asserts every link carries a workspace prefix, but no live test was run against a deployed instance.

Related: Vocion can mail you now.