Dynamic Routing in a Sanity Studio Commerce Tool with the Router and React

How to build a scalable, type-safe routing system for a custom commerce tool inside Sanity Studio using Sanity’s router, a plugin, and lazy-loaded React views.

Engineering11 min readPortrait of Oybek Khalikovic, CTO at Karve DigitalBy Oybek Khalikovic

When you build a custom commerce tool inside Sanity Studio — orders, products, customers, each with create/view/edit screens — you quickly need real routing. Hardcoding a switch statement does not scale, and it breaks deep-linking and back-button behaviour. Sanity ships a small, capable router; here is how to wire it to React with full type safety and lazy-loaded views.

Understanding Sanity’s router

Sanity’s router (the same primitive the Studio uses internally) lets a tool declare routes and read the current route state. You define a route tree, and the tool receives the resolved state — which view, which action, which document id — as props. The job is to map that state to the right React component, and to do it without shipping every screen in the initial bundle.

Define the types and route constants

Start with the shapes. A single source of truth for views, actions, and state keeps every route reference predictable and catches typos at compile time rather than at runtime:

commerce/constants.ts
export type ViewType = "order" | "product" | "customer";
export type ActionType = "create" | "view" | "edit";

export type CommerceState = {
  view: ViewType;
  action?: ActionType;
  id?: string;
};

// Centralise the route segments so they are never hardcoded twice.
export enum CommerceRoutes {
  Order = "order",
  Product = "product",
  Customer = "customer",
}

Create the commerce tool

Generate the route tree from the enum rather than listing routes by hand. Adding a new section becomes a one-line change to the enum:

commerce/tool.ts
import { route } from "sanity/router";
import { CommerceRoutes } from "./constants";
import { CommerceTool } from "./commerce-tool";

export const commerceTool = () => ({
  title: "Commerce",
  name: "commerce-tool",
  component: CommerceTool,
  router: route.create("/", [
    ...Object.values(CommerceRoutes).map((segment) =>
      route.create(`/${segment}/:action/:id`),
    ),
  ]),
});

Wrap it in a plugin

Packaging the tool as a plugin makes it reusable across studios and keeps configuration declarative:

commerce/plugin.ts
import { definePlugin } from "sanity";
import { commerceTool } from "./tool";

export const commercePlugin = definePlugin({
  name: "commerce-plugin",
  tools: [commerceTool()],
});

Render views with dynamic imports

Here is the payoff. A view map keys components by view and action, and each entry is a dynamically imported component — so a screen’s code only loads when its route is visited:

commerce/commerce-view.tsx
import dynamic from "next/dynamic";
import type { CommerceState, ViewType, ActionType } from "./constants";

const viewMap: Partial<Record<ViewType, Partial<Record<ActionType, React.ComponentType<{ state: CommerceState }>>>>> = {
  product: {
    view: dynamic(() => import("./product/product-view")),
    edit: dynamic(() => import("./product/product-edit-view")),
    create: dynamic(() => import("./product/product-create-view")),
  },
  // order, customer … follow the same shape
};

export function CommerceView({ state }: { state: CommerceState }) {
  const Component = state.action ? viewMap[state.view]?.[state.action] : undefined;
  if (!Component) return <div>View not found</div>;
  return <Component state={state} />;
}

Why dynamic imports matter here

A commerce tool can have dozens of screens. Bundling them all means a slow first paint for a Studio users open all day. Mapping route state to dynamic() imports gives you automatic code-splitting: the order list does not pay for the product editor it never opens. The view map keeps that lazy-loading declarative and type-checked, so a missing screen is a compile error, not a blank panel.

Conclusion

The pattern is small but durable: typed state and a route-segment enum as the single source of truth, route generation from that enum, a plugin wrapper for reuse, and a dynamic-import view map for performance. It scales from three sections to thirty without rewrites, and every route reference stays type-safe end to end.

Questions
Why use Sanity’s router instead of React Router inside a tool?

A custom Studio tool runs inside the Studio’s own routing context. Sanity’s router integrates with that context so deep links, the browser back button, and Presentation all behave correctly. Bolting on a separate router fights the Studio shell.

How do I keep routes type-safe?

Define your views, actions, and state as TypeScript unions, and centralise route segments in an enum. Generate the route tree from that enum and key your view map by the same types, so a missing or misspelled route fails at compile time.

How does lazy loading work for the views?

Each entry in the view map is created with next/dynamic, so the component’s code is only fetched when its route is visited. That code-splits a large tool automatically and keeps the Studio’s initial bundle small.

Can this pattern scale to many sections?

Yes. Because routes are generated from an enum and views are resolved through a typed map, adding a section is a one-line enum change plus its view-map entries — no switch statements to grow and no route strings to duplicate.

Talk to the people
who wrote this.

Let's Talk