# Federated tabs

While it's typically recommended for a Squide application to maintain the boundary of a page within a single domain, there are situations where enhancing the user experience necessitates rendering a page with parts from multiple domains, or at the very least, simulating it 😊.

For this guide, we'll take as an example a page for which the parts that are owned by different domains are organized by tabs (federated tabs):

  • Tab 1: Registered by Remote Module 1
  • Tab 2: Registered by Remote Module 2
  • Tab 3: Registered by Local Module

Anatomy of a page rendering federated tabs
Anatomy of a page rendering federated tabs

# Define a nested layout

To construct this page while adhering to Squide constraint of exclusively permitting route and navigation items exports from modules, let's begin by defining a React Router nested layout. This nested layout will be responsible for rendering all the tab headers and the content of the active tab:

remote-module-3/src/federated-tabs-layout.tsx
import { Suspense } from "react";
import { Link, Outlet } from "react-router-dom";

export function FederatedTabsLayout() {
    return (
        <div>
            <p>Every tab is registered by a different module.</p>
            <ul style={{ listStyleType: "none", margin: 0, padding: 0, display: "flex", gap: "20px" }}>
                <li><Link to="federated-tabs/tab-1">Tab 1</Link></li>
                <li><Link to="federated-tabs/tab-2">Tab 2</Link></li>
                <li><Link to="federated-tabs/tab-3">Tab 3</Link></li>
            </ul>
            <div style={{ padding: "20px" }}>
                <Suspense fallback={<div>Loading...</div>}>
                    <Outlet />
                </Suspense>
            </div>
        </div>
    );
}

In the previous code sample, the FederatedTabsLayout is similar to the RootLayout introduced in previous guides. However, the key distinction is that this layout is nested under the /federated-tabs URL segment. By nesting the layout under a specific path, it will only render when the user navigates to one of the federated tab pages (e.g. /federated-tabs/tab-1, /federated-tabs/tab-2, /federated-tabs/tab-3).

To register the newly created layout as a nested layout, use the registerRoute function:

remote-module-3/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { FederatedTabsLayout } from "./FederatedTabsLayout.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // Register the layout as a nested layout under the "/federated-tabs" URL segment.
        path: "/federated-tabs",
        element: <FederatedTabsLayout />
    });

    runtime.registerNavigationItem({
        $label: "Federated tabs",
        to: "/federated-tabs"
    });
}

With this nested layout in place, thanks to the React Router Outlet component, the content of the tabs can now reside in distinct pages (registered by different modules) while still delivering a cohesive user experience. Whenever a user navigates between the tabs, the URL will be updated, and the tab content will change, but the shared portion of the layout will remain consistent.

As a bonus, each individual tab will have its own dedicated URL! 🥳

# Create the tab routes

Next, let's add the actual tab pages to the modules. To do so, we'll use the parentPath option of the registerRoute function to register the routes under the FederatedTabsLayout:

remote-module-1/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab1 } from "./Tab1.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // Using "index: true" instead of a path because this is the default active tab.
        index: true
        element: <Tab1 />
    }, { 
        parentPath: "/federated-tabs"
    });
}
remote-module-1/src/Tab1.tsx
export function Tab1() {
    return (
        <div>Hey, this is Tab 1 content</div>
    );
}
remote-module-2/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab2 } from "./Tab2.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (FederatedTabsLayout).
        path: "/federated-tabs/tab-2"
        element: <Tab2 />
    }, { 
        parentPath: "/federated-tabs"
    });
}
remote-module-2/src/Tab2.tsx
export function Tab2() {
    return (
        <div>Hey, this is Tab 2 content</div>
    );
}
local-module/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab3 } from "./Tab3.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (FederatedTabsLayout).
        path: "/federated-tabs/tab-3"
        element: <Tab3 />
    }, {
        parentPath: "/federated-tabs"
    });
}
local-module/src/Tab3.tsx
export function Tab3() {
    return (
        <div>Hey, this is Tab 3 content</div>
    );
}

Now that the tabs has been registered, ensure that all four modules (including remote-module-3) are registered in the host application. Start the development servers using the dev script. Navigate to the /federated-tabs page, you should see the tab headers. Click on each tab header to confirm that the content renders correctly.

# Decouple the navigation items

Althought it's functional, the modules are currently coupled by hardcoded URLs within the FederatedTabsLayout.

To decouple the navigation items, similar to what is done for regular federated pages, we'll utilize the registerNavigationItem function. In this case, we'll also use the menuId option. Defining the menuId option will enable the FederatedTabsLayout to retrieve navigation items exclusively for the federated tab component.

First, let's register the navigation items with the menuId option. For this example the menuId will be /federated-tabs (it can be anything):

remote-module-1/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab1 } from "./Tab1.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // Using "index: true" instead of a path because this is the default active tab.
        index: true
        element: <Tab1 />
    }, { 
        parentPath: "/federated-tabs" 
    });

    runtime.registerNavigationItem({
        $label: "Tab 1",
        to: "/federated-tabs"
    }, { 
        // The menu id could be anything, in this example we are using the same path as the nested layout
        // path for convenience.
        menuId: "/federated-tabs"
    });
}
remote-module-2/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab2 } from "./Tab2.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (FederatedTabsLayout).
        path: "/federated-tabs/tab-2"
        element: <Tab2 />
    }, { 
        parentPath: "/federated-tabs"
    });

    runtime.registerNavigationItem({
        $label: "Tab 2",
        to: "/federated-tabs/tab-2"
    }, { 
        // The menu id could be anything, in this example we are using the same path as the nested layout
        // path for convenience.
        menuId: "/federated-tabs"
    });
}
local-module/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab3 } from "./Tab3.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        // React Router nested routes requires the first part of the "path" to be the same 
        // as the nested layout path (FederatedTabsLayout).
        path: "/federated-tabs/tab-3"
        element: <Tab3 />
    }, { 
        parentPath: "/federated-tabs"
    });

    runtime.registerNavigationItem({
        $label: "Tab 3",
        to: "/federated-tabs/tab-3"
    }, {
        // The menu id could be anything, in this example we are using the same path as the nested layout
        // path for convenience. 
        menuId: "/federated-tabs" 
    });
}

Then, update the FederatedTabsLayout to render the registered navigation items instead of the hardcoded URLs:

remote-module-3/src/federated-tabs-layout.tsx
import { 
    useNavigationItems,
    useRenderedNavigationItems,
    type NavigationLinkRenderProps,
    type RenderItemFunction,
    type RenderSectionFunction
} from "@squide/react-router";
import { Suspense } from "react";
import { Link, Outlet } from "react-router-dom";

const renderItem: RenderItemFunction = (item, index, level) => {
    const { label, linkProps } = item as NavigationLinkRenderProps;

    return (
        <li key={`${level}-${index}`}>
            <Link {...linkProps}>
                {label}
            </Link>
        </li>
    );
};

const renderSection: RenderSectionFunction = elements => {
    return (
        <ul style={{ listStyleType: "none", margin: 0, padding: 0, display: "flex", gap: "20px" }}>
            {elements}
        </ul>
    );
};

export function FederatedTabsLayout() {
    const navigationItems = useNavigationItems({ menuId: "/federated-tabs" });
    const renderedTabs = useRenderedNavigationItems(navigationItems, renderItem, renderSection);

    return (
        <div>
            <p>Every tab is registered by a different module.</p>
            {renderedTabs}
            <div style={{ padding: "20px" }}>
                <Suspense fallback={<div>Loading...</div>}>
                    <Outlet />
                </Suspense>
            </div>
        </div>
    );
}

# Change the display order of the tabs

Similarly to how the display order of regular navigation items can be configured, a federated tab position can be affected with the priority property.

To force Tab 3 to be positioned first, we'll give him a priority of 999:

local-module/src/register.tsx
import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import { Tab3 } from "./Tab3.tsx";

export const register: ModuleRegisterFunction<FireflyRuntime> = runtime => {
    runtime.registerRoute({
        path: "/federated-tabs/tab-3"
        element: <Tab3 />
    }, { 
        parentPath: "/federated-tabs"
    });

    runtime.registerNavigationItem({
        $label: "Tab 3",
        // Highest priority goes first.
        $priority: 999,
        to: "/federated-tabs/tab-3"
    }, { 
        menuId: "/federated-tabs" 
    });
}

# Try it 🚀

To ensure everything is still working correctly, start the development servers using the dev script and navigate to the /federated-tabs page. You should see all three tabs, and you should be able to switch between them by clicking on the tab headers.

# Troubleshoot issues

If you are experiencing issues with this guide:

  • Open the DevTools console. You'll find a log entry for each registration that occurs and error messages if something went wrong:
    • [squide] The following route has been registered as a children of the "/federated-tabs" route. Newly registered item: ...
    • [squide] The following navigation item has been registered to the "/federated-tabs" menu for a total of 1 item. Newly registered item: ...
  • Refer to a working example on GitHub.
  • Refer to the troubleshooting page.