featured image

Building a Command Router Pattern for Terminal Interfaces

Learn how to architect a command router in TypeScript and React to handle terminal-style user inputs with clean separation of concerns.

Published

Sun Jun 15 2025

Technologies Used

Typescript React
Beginner 11 minutes

When I started building the terminal interface for this portfolio, my first instinct was to handle commands directly inside the component. That instinct was wrong.

The problem isn’t adding help or about. It’s what happens after you add ten commands. The component becomes a 1000-line monolith where adding a new command means editing the component, testing means mounting the entire terminal, and the routing logic is tangled up with the rendering logic. None of that is fine, and all of it is avoidable.

The code in src/lib/renderCmd.tsx uses a command router pattern: parse the input, map it to a component, return a standardized result. That’s it. This tutorial walks through how it works and why the structure matters.

What You Need Coming In

You need JavaScript/TypeScript basics (functions, objects, arrays), React fundamentals (components, JSX), and string manipulation. That’s genuinely it — this is a beginner post. Familiarity with routing concepts from Express or React Router will help you recognize what’s happening, but it’s not required.

The file structure we’re working with:

src/
  lib/
    renderCmd.tsx          // Command router
  components/
    commands/
      Help.tsx
      About.tsx
      Skills.tsx
      Contact.tsx
      Bio.tsx
      Ls.tsx
      NotFound.tsx

A Standardized Return Type for Every Command

Before writing the router, we need a single type that every command returns. This is the contract the terminal component depends on:

type CmdHistory = {
  cmd: string;              // Original command string
  Component: () => JSX.Element;  // React component to render
  time: string;             // Timestamp (populated by caller)
};

Using Component: () => JSX.Element instead of Component: JSX.Element matters here. The former is a function that returns JSX — it can be called multiple times, it closes over variables, and it defers rendering until the terminal needs it. The latter is a value rendered once and cached. For commands like cd that create inline components closing over the argument, you need the function form.

The cd command needs to know what destinations are valid. Rather than scattering this across the switch statement, I put it in one place:

const pages = [
  "projects",
  "tutorials",
  "categories",
  "tags",
  "contact",
  "about",
  "github",
  "linkedin",
];

const externalLinks: { [key: string]: string } = {
  "github": "https://github.com/jason-n-tran",
  "linkedin": "https://www.linkedin.com/in/jason-n-tran",
};

Single source of truth. Adding a new page is one line in one place. External links are separated from internal routes, which matters because they navigate differently. If you later want autocomplete, this array is already the right data structure to drive it.

The Router Function

function renderCmd(cmd: string): CmdHistory {
  const [command, arg] = cmd.split(" ");
  
  switch (command) {
    case "help":
      return { cmd, Component: Help, time: "" };
    
    case "whois":
      return { cmd, Component: Bio, time: "" };
    
    case "skills":
      return { cmd, Component: Skills, time: "" };
    
    case "contact":
      return { cmd, Component: Contact, time: "" };
    
    case "about":
      return { cmd, Component: About, time: "" };
    
    case "ls":
      return { cmd, Component: Ls, time: "" };
    
    case "cd": {
      if (arg && pages.includes(arg)) {
        if (externalLinks[arg]) {
          window.open(externalLinks[arg], "_blank");
          const Redirecting = () => <p>Opening {arg} in a new tab...</p>;
          return { cmd, Component: Redirecting, time: "" };
        } else {
          window.location.href = `/${arg}`;
          const Redirecting = () => <p>Redirecting to /{arg}...</p>;
          return { cmd, Component: Redirecting, time: "" };
        }
      }
      return {
        cmd,
        Component: () => (
          <p>Invalid directory. Type 'ls' to see available directories.</p>
        ),
        time: "",
      };
    }
    
    default:
      return { cmd, Component: NotFound, time: "" };
  }
}

Notice we’re returning the component function itself, not calling it. The terminal component calls it later:

<enteredCmd.Component />

This lazy evaluation is what lets us store command history as data rather than rendered output.

One thing to watch out for: the split(" ") approach only handles single arguments. A command like cd my folder would treat "my" as the argument and lose "folder". For this portfolio it doesn’t matter — all pages are single words. If you’re building something more complex, you’d use const [command, ...args] = cmd.split(" ") and rejoin the rest.

Why a Switch Statement Over an Object Map

You might notice the simple commands could be written as an object lookup:

const simpleCommands = { help: Help, whois: Bio, skills: Skills };
const Component = simpleCommands[command] || NotFound;
return { cmd, Component, time: "" };

That works for commands without arguments. But cd needs validation logic and navigation side effects — it can’t be a simple map entry. Rather than mix two patterns, I used a switch throughout and accepted the verbosity for the simple cases. The hybrid approach (map for simple, switch for complex) is arguably cleaner if you have many commands, but for six simple commands and one complex one, a single switch is easier to read.

Handling the Switch on the Terminal Side

The cd case calls window.location.href inside the router — which is a side effect in what otherwise looks like a pure function. This makes the router harder to test in isolation. A cleaner approach would return a navigation intent and let the terminal component handle it:

type CmdHistory = {
  cmd: string;
  Component: () => JSX.Element;
  time: string;
  navigation?: { type: 'internal' | 'external', url: string };
};

Then the terminal component checks result.navigation and calls window.open or sets window.location.href itself. I didn’t implement it this way in the portfolio because the router is simple enough that the trade-off wasn’t worth the added type complexity. But for any system where you want to unit test the router without mocking window, this is the right structure.

Extending the Router

Adding a new command is three steps: create the component, add a case to the switch, update Ls.tsx to list it.

// 1. Create the component
// src/components/commands/Projects.tsx
export default function Projects() {
  return <div>My projects...</div>;
}

// 2. Add to router
import Projects from "@/components/commands/Projects";

case "projects":
  return { cmd, Component: Projects, time: "" };

Adding aliases is one extra case:

case "help":
case "?":
case "h":
  return { cmd, Component: Help, time: "" };

Adding flags to existing commands:

case "ls": {
  const showHidden = arg === "-a" || arg === "--all";
  const LsComponent = () => <Ls showHidden={showHidden} />;
  return { cmd, Component: LsComponent, time: "" };
}

The mental model to carry forward: a command router is a separate architectural layer that maps string inputs to executable code. It doesn’t render anything — it just routes. The same idea shows up in Express route handlers, Redux action reducers, and plugin systems. Once you see it, you recognize it everywhere.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!