> ## Documentation Index
> Fetch the complete documentation index at: https://developer.jtl-software.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build the Frontend

> Scaffold the React frontend for your JTL Cloud App with TanStack Router and the JTL AppBridge SDK.

Build the React frontend for a JTL Cloud App using Vite, TypeScript, [TanStack Router](https://tanstack.com/router/latest), and the JTL AppBridge SDK. By the end, the app runs locally with three working routes and a clear state for connecting to your backend.

**Stack:** Vite, React, TypeScript, TanStack Router (file-based routing), JTL AppBridge, JTL Platform UI.

## Prerequisites

You need:

* ✅ **A JTL ID** (your login to the JTL ecosystem)
* ✅ **Access to an organization (tenant) in the [Partner Portal](https://partner.jtl-cloud.com)** (created automatically on first login)
* ✅ **[Node.js](https://nodejs.org/) v24.16.0 or higher** (current LTS, includes `npm`). Verify with `node --version` and `npm --version`

If you don't have your JTL ID and Partner Portal access yet, follow [Create a Developer Account](/get-started/create-developer-account) first.

## What you're Building

Before writing code, here’s how a JTL Cloud App works:

```mermaid theme={null}
sequenceDiagram
    participant Shell as JTL App Shell
    participant FE as Your Frontend
    participant BE as Your Backend

    Shell->>FE: Loads app in iframe
    FE->>Shell: appBridge.method.call('getSessionToken')
    Shell-->>FE: Session token (JWT)
    FE->>BE: GET /api/connect-tenant (with token)
    BE-->>FE: Verified tenant info
```

Your frontend runs inside JTL’s App Shell (in an iframe). It communicates with the shell through **AppBridge**, a small SDK that exposes shell capabilities to the iframe. The most important one is `getSessionToken`, a short-lived signed token that proves which merchant (tenant) is currently using your app. Your frontend sends that token to your backend, which verifies it and uses it to make tenant-scoped calls to the JTL API.

Not every page in your app runs inside the shell. The frontend has three routes, and they fall into two groups:

* `/setup` and `/erp` run inside the App Shell iframe. AppBridge is available, and the app knows which tenant it's serving.
* `/hub` opens in a standalone browser tab when a merchant clicks the app card. There’s no iframe, no AppBridge, and no tenant context.

These two contexts require slightly different setup. You’ll handle that when building the frontend.

## 1. Create the Project

```bash theme={null}
mkdir my-jtl-app
cd my-jtl-app

npm create vite@latest frontend -- --template react-ts
```

When prompted:

* **Ok to proceed? (y):** `y`
* **Install with npm and start now?:** `Yes`

Once Vite finishes installing, press `Ctrl + C` to stop the dev server.

## 2. Install Packages

```bash theme={null}
cd frontend
npm install @tanstack/react-router @jtl-software/cloud-apps-core @jtl-software/platform-ui-react tailwindcss @tailwindcss/vite
npm install -D @tanstack/router-plugin @tanstack/react-router-devtools
```

| Package                             | Purpose                                                                 |
| ----------------------------------- | ----------------------------------------------------------------------- |
| `@tanstack/react-router`            | Type-safe router with file-based routing                                |
| `@tanstack/router-plugin`           | Vite plugin that auto-generates the route tree from your file structure |
| `@tanstack/react-router-devtools`   | Devtools panel for inspecting routes (dev only)                         |
| `@jtl-software/cloud-apps-core`     | AppBridge SDK: communication between your app and the JTL App Shell     |
| `@jtl-software/platform-ui-react`   | JTL's UI component library (Button, Card, Text, etc.)                   |
| `tailwindcss` + `@tailwindcss/vite` | Utility-first CSS, required by the JTL UI library                       |

## 3. Configure Vite

Replace `frontend/vite.config.ts`:

```typescript theme={null}
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { tanstackRouter } from '@tanstack/router-plugin/vite';
import path from 'path';

export default defineConfig({
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:5273',
        changeOrigin: true,
      },
    },
  },
  plugins: [
    // The router plugin must come before the React plugin
    tanstackRouter({ target: 'react', autoCodeSplitting: true }),
    react(),
    tailwindcss(),
  ],
  resolve: {
    alias: {
      '/assets': path.join(
        path.dirname(require.resolve('@jtl-software/platform-ui-react')),
        'assets',
      ),
    },
  },
});
```

This does three things:

* **TanStack Router plugin** watches `src/routes/` and regenerates `src/routeTree.gen.ts` whenever you add or rename a route file. The plugin must be listed before `react()`. See the [installation docs](https://tanstack.com/router/latest/docs/installation/with-vite) for details.
* **Tailwind plugin** enables Tailwind utilities, which the JTL UI library depends on.
* **Dev proxy** forwards `/api/*` requests to a backend on `localhost:5273`. Your frontend can call `fetch('/api/...')` and Vite will route the request without CORS issues during development.

## 4. Set up Styles

Replace `frontend/src/index.css`:

```css theme={null}
@import '@jtl-software/platform-ui-react/index.css';
@import 'tailwindcss';
```

## 5. Create the Route Files

TanStack Router uses file-based routing, so your folder structure under `src/routes/` *is* your route configuration. Delete the default `src/App.tsx` (the router replaces it), then create the following structure:

```
frontend/src/routes/
├── __root.tsx              # Root layout (devtools, outlet)
├── _shell.tsx              # Pathless layout. Only wraps Outlet in that renders inside the App Shell
├── _shell.setup.tsx        # /setup (inside App Shell)
├── _shell.erp.tsx          # /erp (inside App Shell)
└── hub.tsx                 # /hub (standalone, no AppBridge)
```

Two conventions are in play here:

* **`__root.tsx`** is the top-level layout, always rendered.
* **`_shell.tsx`** is a [pathless layout route](https://tanstack.com/router/latest/docs/routing/routing-concepts#pathless-layout-routes). The leading underscore means it doesn't add a URL segment, so `_shell.setup.tsx` becomes `/setup`, not `/_shell/setup`. Any route file prefixed with `_shell.` shares the layout's wrapper. `hub.tsx` doesn't, so it bypasses the AppBridge initialization entirely.

### Root Route

Add the following to your `frontend/src/routes/__root.tsx`:

```tsx theme={null}
import { createRootRoute, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
 
export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
      <TanStackRouterDevtools />
    </>
  ),
});
```

The `<Outlet />` is where child routes render. During development, a devtools panel appears in the bottom corner. It won’t be included in your production build.

### Shell Layout (AppBridge Provider)

This layout wraps every iframe route.

`frontend/src/routes/_shell.tsx`:

```tsx theme={null}
import { createFileRoute, Outlet } from '@tanstack/react-router';

export const Route = createFileRoute('/_shell')({
	component: () => <Outlet />,
});
```

Then create a `frontend/src/appBridge.tsx` file that initializes AppBridge once, exposes the bridge and tenant ID through React Context, and shows a loading state until the connection completes.

```tsx theme={null}
import {
	createContext,
	useContext,
	useEffect,
	useState,
	type ReactNode,
} from 'react';
import type { AppBridge } from '@jtl-software/cloud-apps-core';

interface AppBridgeContextValue {
	appBridge: AppBridge | null;
	tenantId: string | null;
	isReady: boolean;
	error: string | null;
}

const AppBridgeContext = createContext<AppBridgeContextValue>({
	appBridge: null,
	tenantId: null,
	isReady: false,
	error: null,
});

export function useAppBridge() {
	return useContext(AppBridgeContext);
}

export function AppBridgeProvider({ children }: { children: ReactNode }) {
	const [appBridge, setAppBridge] = useState<AppBridge | null>(null);
	const [tenantId, setTenantId] = useState<string | null>(null);
	const [isReady, setIsReady] = useState(false);
	const [error, setError] = useState<string | null>(null);

	useEffect(() => {
		let cancelled = false;

		async function init() {
			try {
				const { createAppBridge } =
					await import('@jtl-software/cloud-apps-core');
				const bridge = await createAppBridge();
				if (cancelled) return;
				setAppBridge(bridge);

				const sessionToken =
					await bridge.method.call<string>('getSessionToken');
				if (cancelled) return;

				const res = await fetch('/api/connect-tenant', {
					headers: { 'X-Session-ID': sessionToken },
				});
				if (cancelled) return;

				if (!res.ok) {
					const data = await res.json().catch(() => null);
					throw new Error(
						data?.error || `Connect failed (${res.status})`,
					);
				}

				const data = await res.json();
				if (cancelled) return;
				setTenantId(data.tenantId);
				setIsReady(true);
			} catch (err) {
				if (cancelled) return;
				console.error('[AppBridge] Init failed:', err);
				setError(
					err instanceof Error ? err.message : 'Failed to initialize',
				);
				setIsReady(true);
			}
		}

		init();

		return () => {
			cancelled = true;
		};
	}, []);

	if (!isReady) {
		return (
			<div className='flex min-h-screen items-center justify-center'>
				<div className='text-center'>
					<div className='mx-auto size-8 animate-spin rounded-full border-4 border-gray-200 border-t-orange-500' />
					<p className='mt-4 text-sm text-gray-500'>
						Connecting to JTL Platform...
					</p>
				</div>
			</div>
		);
	}

	return (
		<AppBridgeContext.Provider
			value={{ appBridge, tenantId, isReady, error }}
		>
			{children}
		</AppBridgeContext.Provider>
	);
}
```

Two things to note:

* **Dynamic import** of `createAppBridge` keeps the SDK out of any pre-render path. AppBridge requires `window`, so it can only run client-side.
* **React Context** exposes `appBridge`, `tenantId`, and connection state through the `useAppBridge()` hook. Any route that is a child of the `_shell` route can call it with no prop drilling and a single shared connection state.

### Setup Page

Add the following to your `frontend/src/routes/_shell.setup.tsx`:

```tsx theme={null}
import { createFileRoute } from '@tanstack/react-router';
import { Button, Box, Text } from '@jtl-software/platform-ui-react';
import { useAppBridge } from '../appBridge';
 
function SetupPage() {
  const { appBridge, tenantId, error } = useAppBridge();
 
  const handleSetupCompleted = async () => {
    if (!appBridge) return;
    try {
      await appBridge.method.call('setupCompleted');
    } catch (err) {
      console.error('Setup completion failed:', err);
    }
  };
 
  if (error) {
    return (
      <Box className="flex flex-col items-center justify-center min-h-screen gap-4 p-8">
        <Text type="h2" weight="bold" as="h1">Waiting for backend</Text>
        <Box className="max-w-md text-center">
          <Text type="body" color="muted" align="center">
            The frontend is running, but no backend is responding at <code>/api/connect-tenant</code>.
            Start your backend and reload this page.
          </Text>
        </Box>
        <Box className="bg-gray-50 rounded-lg p-3 mt-2">
          <Text type="small" color="muted">{error}</Text>
        </Box>
      </Box>
    );
  }
 
  return (
    <Box className="flex flex-col items-center justify-center min-h-screen gap-6">
      <Text type="h2" weight="bold" as="h1">Connect to JTL Cloud</Text>
      <Box className="max-w-md text-center">
        <Text type="body" color="muted" align="center">
          Your app has been verified and is ready to connect.
        </Text>
      </Box>
      {tenantId && (
        <Text type="small" color="muted">
          Tenant: <code className="bg-gray-100 px-2 py-1 rounded">{tenantId}</code>
        </Text>
      )}
      <Button onClick={handleSetupCompleted} label="Complete Setup" />
    </Box>
  );
}
 
export const Route = createFileRoute('/_shell/setup')({
  component: SetupPage,
});
```

Uses the `useAppBridge()` hook to connect to the tenant. The error branch renders whenever `/api/connect-tenant` is unreachable or returns a non-2xx response. The success branch renders once a backend verifies the session token and returns a tenant ID.

### ERP Page

Add the following to your `frontend/src/routes/_shell.erp.tsx`:

```tsx theme={null}
import { createFileRoute } from '@tanstack/react-router';
import { Box, Text } from '@jtl-software/platform-ui-react';
import { useAppBridge } from '../appBridge';
 
function ErpPage() {
  const { tenantId, error } = useAppBridge();
 
  if (error) {
    return (
      <Box className="p-8">
        <Text type="h2" weight="bold" as="h1">Waiting for backend</Text>
        <Box className="mt-2">
          <Text type="body" color="muted">
            Start your backend and reload this page.
          </Text>
        </Box>
      </Box>
    );
  }
 
  return (
    <Box className="p-8">
      <Box className="mb-4">
        <Text type="h2" weight="bold" as="h1">My JTL Cloud App</Text>
      </Box>
      <Box className="mb-6">
        <Text type="body" color="muted">
          Your app is running inside the JTL Cloud and connected to your tenant.
        </Text>
      </Box>
 
      {tenantId && (
        <Box className="bg-gray-50 rounded-lg p-4 font-mono text-sm">
          <Text as="p"><strong>Tenant ID:</strong> {tenantId}</Text>
        </Box>
      )}
    </Box>
  );
}
 
export const Route = createFileRoute('/_shell/erp')({
  component: ErpPage,
});
```

This is the page merchants see when they open your app from the ERP menu.

### Hub Page

This route sits outside the shell layout and doesn't inherit AppBridge initialization. When a merchant clicks the app card in the [JTL Hub](https://hub.jtl-cloud.com/), this page opens in a full browser tab with no iframe and no session token.

`frontend/src/routes/hub.tsx`:

```tsx theme={null}
import { createFileRoute } from '@tanstack/react-router';
import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
  CardFooter,
  Text,
  Box,
  Button,
} from '@jtl-software/platform-ui-react';
import { ArrowUpRight } from 'lucide-react';
 
function HubPage() {
  return (
    <Box className="min-h-screen w-full flex items-center justify-center p-8">
      <Card className="max-w-[560px] w-full">
        <CardHeader className="items-center text-center gap-4 pt-10 pb-6">
          <CardTitle className="text-2xl font-semibold">Launched from JTL Cloud</CardTitle>
          <CardDescription className="text-center max-w-[420px]">
            This page opens in a full browser tab when a merchant clicks your app card
            in the JTL Hub. It runs outside the App Shell, so there is no AppBridge
            and no tenant context.
          </CardDescription>
        </CardHeader>
 
        <CardContent className="px-6 pb-6">
          <Box>
            <Text type="xs" weight="semibold" color="muted">Manifest mapping</Text>
            <Text type="inline-code">capabilities.hub.appLauncher.redirectUrl</Text>
          </Box>
        </CardContent>
 
        <CardFooter className="px-6 pb-8 pt-2">
          <a href="https://hub.jtl-cloud.com/" target="_blank" rel="noopener noreferrer">
            <Button
              variant="default"
              fullWidth
              label="Open JTL Cloud Hub"
              icon={<ArrowUpRight size={16} strokeWidth={2} />}
              iconPosition="right"
            />
          </a>
        </CardFooter>
      </Card>
    </Box>
  );
}
 
export const Route = createFileRoute('/hub')({
  component: HubPage,
});
```

## 6. Wire up the Router and Add AppBridgeProvider

Update `frontend/src/main.tsx` to initialize the router and wrap your shell layout with AppBridgeProvider.

```tsx theme={null}
import ReactDOM from 'react-dom/client';
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
import { AppBridgeProvider } from './appBridge';
import './index.css';

const router = createRouter({ routeTree });

declare module '@tanstack/react-router' {
	interface Register {
		router: typeof router;
	}
}

const needsBridge = !window.location.pathname.startsWith('/hub');

const tree = needsBridge ? (
	<AppBridgeProvider>
		<RouterProvider router={router} />
	</AppBridgeProvider>
) : (
	<RouterProvider router={router} />
);

const rootElement = document.getElementById('root')!;
if (!rootElement.innerHTML) {
	ReactDOM.createRoot(rootElement).render(tree);
}
```

## 7. Run the Frontend

Start the dev server:

```bash theme={null}
npm run dev
```

The first time you run this, the TanStack Router plugin generates `src/routeTree.gen.ts` automatically.

Visit each route to confirm the app works:

<Steps>
  <Step title="Visit http://localhost:5173/setup">
    You should see a spinner and then a message: **"Waiting for backend"**. This shows the routing works and the shell layout mounts, AppBridge attempts to initialize, and the fetch to `/api/connect-tenant` fails (no backend yet).
  </Step>

  <Step title="Visit http://localhost:5173/erp">
    Same waiting message as before. Confirms the second iframe route loads and reuses the shell layout.
  </Step>

  <Step title="Visit http://localhost:5173/hub">
    You should see the **"Launched from JTL Cloud"** card. No spinner, no error, since this route bypasses the shell layout entirely.
  </Step>
</Steps>

## Common Issues

<AccordionGroup>
  <Accordion title="'window is not defined' or 'AppBridge is not defined'">
    This error occurs when AppBridge is loaded outside a browser environment. It relies on the `window` object, which is not available during server-side rendering or in Node.

    In the shell layout, the SDK should be loaded using a dynamic `await import('@jtl-software/cloud-apps-core')` inside `useEffect`. If it is moved to a top-level `import`, it runs too early and triggers this error.

    Also make sure you are running `npm run dev`. This app is a client-only Vite app and is not designed to run in a server environment.
  </Accordion>

  <Accordion title="'Cannot find module ./routeTree.gen' in main.tsx">
    This means the route tree file has not been generated yet. It is created automatically by the TanStack Router Vite plugin when it detects your route files.

    Start the dev server with `npm run dev`, then save any file inside `src/routes/`. The plugin watches this folder and will generate `src/routeTree.gen.ts` as soon as it detects a change.

    If the file still does not appear, check `vite.config.ts`. The `tanstackRouter()` plugin must be listed before `react()` in the `plugins` array.
  </Accordion>

  <Accordion title="Routes resolve to /_shell/setup instead of /setup">
    This usually means the route is not being treated as a pathless layout.

    In TanStack Router, the leading underscore (`_`) marks a route as pathless. If the file is named `shell.setup.tsx` (without the underscore), `_shell` becomes part of the URL.

    Rename the file to `_shell.setup.tsx` to restore the expected behavior. Also make sure you are using the flat-file convention (`_shell.setup.tsx`), not a folder structure like `_shell/setup.tsx`, since this guide assumes the flat-file format.
  </Accordion>

  <Accordion title="useAppBridge() returns null inside a child route, even after the spinner finishes">
    This usually happens when `AppBridgeContext` and `useAppBridge` are defined in the same file as a TanStack Router route (for example, `_shell.tsx`).

    During development, the router’s Vite integration can create multiple module instances under hot module replacement (HMR). This causes the provider and consumer to reference different context objects, so `useAppBridge()` returns `null`.

    **Fix:** Move the context, hook, and provider into a separate file (for example, `src/appBridge.tsx`) and import `useAppBridge` from there in all components.
  </Accordion>
</AccordionGroup>

## Next: Build the Backend

Pick a language to build the backend that will verify session tokens and proxy requests to the JTL API:

<CardGroup cols={2}>
  <Card title="Node.js" icon="node-js" href="/get-started/quick-start/from-scratch/backend-node">
    Express and TypeScript.
  </Card>

  <Card title="C# (.NET)" icon="code" href="/get-started/quick-start/from-scratch/backend-csharp">
    ASP.NET Core 8.
  </Card>

  <Card title="PHP" icon="php" href="/get-started/quick-start/from-scratch/backend-php">
    Slim 4 and PHP.
  </Card>
</CardGroup>
