Skip to main content
Back to blog Samuel
Mortenson

Creating product demos by mocking your API (feat. tRPC)

One of my first software engineering jobs was, weirdly enough, in Sales. I did Presales, which is kind of like the technical arm of a sales pitch, building interactive demos that we could use to show off our products to prospective customers. I used to joke that I was the guy who sharpens knives for a much more personable salesperson to sell door-to-door.

While the demos varied in complexity, they all required an immense amount of effort to stand up. We sold content management systems, and every demo needed to be a working web app with a real database so we could show off editing content “live”.

That was the PHP days where HTML was rendered server-side, but we’ve come a long way and now most products are built as single page apps (SPAs), where your backend just serves up an API for something like React to consume in the client. Since a server isn't required to at least view the site anymore, making demos should be a lot easier!

This year I found myself building one of those SPAs, and wondered how I could show off my product entirely in the frontend without forcing users to sign up (or even worse, schedule a sales demo).

My first idea was to strictly isolate backend API requests from my React components, then wherever an API request was made, have a “switch” that hardcodes demo content instead of hitting the backend. Kind of like a Storybook, but for the entire logged in experience. My attempt at this went horribly - I had random API requests inside deeply nested components and, in general, did not treat components as “stateful” and “stateless” in a way that would support something like responses only being consumed from props.

After giving up on my dreams of being a good React developer, I then decided to try mocking my entire API at a higher level, and change nothing about how my components render - from their perspective, the API requests would be real.

I use tRPC, which is a TypeScript library that makes use of inferred types and lets you quickly define backend routes that can be used by clients (your frontend) without “manually” writing request/response types or schemas. tRPC has a concept called links, which are middlewares that can modify requests and responses in the client or server. With links you could identify that a request was made in the demo, then return some hardcoded response data and prevent a real fetch from hitting our backend.

Here’s a simple version of how that could look:

// Our client link, or middleware
const demoLink: TRPCLink<AppRouter> = () => {
  return ({ next, op }) => {
    return observable((observer) => {
      // Detect that we're in the demo
      if (window.location.pathname.startsWith("/demo/")) {
	 // Early return with mock data
        observer.next({
            result: {
              // TODO: make better
              data: {},
            },
          });
  return;
      }
      const unsubscribe = next(op).subscribe(observer);
      return unsubscribe;
    });
  };
};

export const trpcClient = createTRPCClient<AppRouter>({
  // Run our middleware before the one that makes
  // real HTTP requests so we can abort early.
  links: [demoLink, httpBatchLink({ url: "http://localhost:2000" })],
});

This works, and unfortunately type checks, but your frontend can’t really function if every API returns an empty object ("{}"). Using TypeScript, we can instead define an object that includes mocks for every route in our backend:

// Infer the input/output types of our routes
// "AppRouter" is a tRPC type inferred from your server's code
type RouterInput = inferRouterInputs<AppRouter>;
type RouterOutput = inferRouterOutputs<AppRouter>;

// A function that takes a route's input
// and returns (likely hardcoded) output.
type DemoDataGenerator<T extends keyof RouterInput> =
  (input: RouterInput[T]) => RouterOutput[T];

// An exhaustive record where keys are route names
// and values are data generation functions.
type DemoRoutes = {
  [T in keyof RouterInput]: DemoDataGenerator<T>;
};

// Use that with a real object, and now every tRPC
// route must now have a matching DemoDataGenerator!
const demoRoutes: DemoRoutes = {
  sayHelloRoute: ({ name }) => { message: `Hello ${name}!` },
}

const demoLink: TRPCLink<AppRouter> = () => {
  return ({ next, op }) => {
    return observable((observer) => {
      const path = op.path as keyof RouterInput;
      if (window.location.pathname.startsWith("/demo/")) {
        observer.next({
            // Now we get real, relevant data!
            result: { data: demoRoutes[path](op.input) },
          });
  return;
      }
      const unsubscribe = next(op).subscribe(observer);
      return unsubscribe;
    });
  };
};

The middleware above ensures that every backend route has mocked response data, so while a user is viewing the demo, no API requests reach our server.

With this written I had a good proof of concept, but now had the burden of defining mocks for every API in my backend (of which I have over 100). That seemed inefficient since, in my project, the demo was only visible while viewing a specific organization/tenant with the identifier/slug “demo” - routes that weren’t organization specific (like user login) had no reason to be mocked.

So - I had two ideas to slim this down:

  1. Don’t mock routes that aren’t organization-specific - They probably aren't hit in the demo.
  2. Don’t define mocks for organization-specific routes that return an empty response - We can just return "{}" ourselves.

The logic is something like:

A diagram showing the workflow of how a API request gets mocked

To satisfy this design, the DemoRoutes type evolved to:

// The shape of HTTP requests for an organization
// (note: in my real product I use UUIDs, not slugs)
type OrganizationInputShape = {
  slug: string;
};

// Allows us to identify routes that return "{}" and
// do not need mocked.
type EmptyShape = {
  [key: string]: never;
};

// A string union of route names that are organization-specific.
type OrganizationRoute = {
  [T in keyof RouterInput]:
    RouterInput[T] extends OrganizationInputShape
      ? T
      : never;
}[keyof RouterInput];

// A bit cursed looking, but requires definitions
// for organization-specific routes that return a
// response, and optionally allow defs for other org routes.
type DemoRoutes = {
  [ T in OrganizationRoute as RouterOutput[T] extends EmptyShape
    ? never
    : T
  ]: DemoDataGenerator<T>;
} & {
  [ T in OrganizationRoute as RouterOutput[T] extends EmptyShape
    ? T
    : never
  ]?: DemoDataGenerator<T>;
};

Which means I only have to mock routes that are hit while viewing the demo - in my case this reduced the number of routes by about half!

To accommodate the new types, the middleware now has to handle the case where the route has a mock, is organization-specific and returns an empty object, or should be “let through” to the real backend:

const demoLink: TRPCLink<AppRouter> = () => {
  return ({ next, op }) => {
    return observable((observer) => {
      const path = op.path as keyof RouterInput;
      if (location.pathname.startsWith("/demo/")) {
        // Do we have mocks for this route?
        if (path in demoRoutes) {
          observer.next({
              result: { data: demoRoutes[path] },
            });
          return;
        // We didn't mock the route, but the input has a slug, the response
        // for this route must be "{}" and we can just return that here.
        } else if (
            typeof op.input === "object"
            && op.input !== null
            && "slug" in op.input
        ) {
          observer.next({
            result: {
              data: {},
            },
          });
          return;
        }
      }
      const unsubscribe = next(op).subscribe(observer);
      return unsubscribe;
    });
  };
};

Beyond requiring less mocked routes, this also allows real API calls to occur while viewing the demo, which is useful for detecting a logged in user and tracking engagement. It also throws type errors when I forget to mock an organization-specific route, which keeps me in check as I add new features.

After finishing my initial set of required mocks, I found that the demo felt a bit stale since it was read only. Hitting "submit" on most forms did nothing, but showed a toast like "Form saved" which was a little confusing as a user.

I set out to make mocked data seem more real, and introduced mutability within the demo by writing updates to memory (local variables). There’s a lot of ways to handle those writes, but here is a simple implementation:

// Assuming we have these routes in the server:
const appRouter = router({
  listPosts: publicProcedure
    .input(
      z.object({
        slug: z.string().nonempty(),
      }),
    )
    .query(async ({ input }) => {
      // (some db code sets posts)
      return posts as { id: string, text: string }[]
    },
  createPost: publicProcedure
    .input(
      z.object({
        slug: z.string().nonempty(),
        text: z.string().nonempty(),
      }),
    )
    .mutation(async ({ input }) => {
      // (some db code inserts a post)
      return { postId: id }
    },
});

// We can define an in-memory Map we'll use
// for post CRUD operations. It can (and should)
// include some initial data.
//
// Note that the value type is inferred from the
// response of a route - this feels weird, but is
// really ergonomic if many routes share a response shape.
const postsStore = new Map<string, RouterOutput["listPosts"][number]>(
  [
    "I am a default, hardcoded post",
    "And so am I!",
  ].map(
    text => {
      const id = crypto.randomUUID()
      return [id, { id, text }]
    }
  )
);

export const demoRoutes: DemoRoutes = {
  // Mocking listings is quite easy
  listPosts: () => ([...postsStore.values()]),
  // And mutating data is also not too bad
  createPost: ({ text }) => {
    const id = crypto.randomUUID();
    postsStore.set(id, { id, text });
    return { postId: id };
  },
};

As you can see, it’s kind of a can of worms for how complex you want your demo to be! I think static, read-only demo data is totally serviceable to show off a product as well, if you want to avoid the “uncanny valley” of how real your demo feels.

With all this done, my demo was ready! Note that I did break my vow of simplicity and add some “are we in the demo?” code within my React tree, but most exceptions were done for valid UX reasons (ex: disabling buttons that would never work, showing a demo banner, etc.).

I’m aware that this post is pretty tRPC specific, but I think this kind of API mocking applies to any SPA that uses TypeScript. If your product doesn’t have a demo already, this is a surprisingly nice way to build one! Or, at the very least, feels a lot more clean than mocking data directly in React.

If you’re interested in seeing the code above in action, I created a small repository you can try out locally: https://github.com/mortenson/trpc-demo-link . The bulk of the interesting code lives in the tRPC link and the actual demo mocks. That repo also includes tRPC subscription support, which was quite tricky to get right (if I got it right at all).

The real product demo I did all this work for can be viewed at https://sevhunt.com/o/_demo, which also includes a short tour. I recently added support for quite a lot of mutability there, which definitely makes it feel more alive (although, I somewhat question if it was worth the headache).

Regardless of how you do it, building a great demo is really important for selling a product. Bonus points for demos like the one described above, where users can engage without signing up or talking to sales. Happy building out there!