Skip to main content
Build a JTL Cloud App from scratch using Next.js (App Router) and TypeScript. Unlike the From Template guide, this one walks through every piece so you understand how the architecture works. Time: ~25 minutes What you’ll build: A full-stack Cloud App that authenticates with JTL, completes the setup handshake via AppBridge, and displays products from JTL-Wawi.
Stack: Next.js 14+ (App Router), TypeScript, JTL AppBridge, JTL Platform UI

Prerequisites

1

Accounts Set Up

You need all three accounts from the Create a Developer Account page:
  • JTL-Wawi account
  • Partner Portal account
  • JTL Hub access
2

Tools Installed

You’ll need the following tools installed on your machine:
  • Node.js v18 or higher. Run node --version to check your current version
  • npm. Run npm --version to verify installation
  • Git. Run git --version to confirm it’s installed
Next.js is used here because it lets you build both the frontend and backend in one project (via API routes), which simplifies development. But you can also use any other JavaScript framework of your choice that supports frontend and backend.

What you’re Building

Before writing code, here’s how a JTL Cloud App works: The key insight is that your app runs inside JTL’s App Shell (in an iframe). Communication between your app and the shell happens through AppBridge; a lightweight SDK that handles session tokens, method calls, and events.

1. Create the Project

npx create-next-app@latest jtl-cloud-app --typescript --app --tailwind --eslint
cd jtl-cloud-app
When prompted, accept the defaults. Make sure you select App Router and Tailwind CSS.

2. Install JTL Packages

npm install @jtl-software/cloud-apps-core @jtl-software/platform-ui-react jose
PackagePurpose
@jtl-software/cloud-apps-coreAppBridge SDK: communication between your app and the JTL App Shell
@jtl-software/platform-ui-reactJTL’s UI component library (Button, Card, Input, etc.)
joseJWT verification: used to verify session tokens from the App Shell

3. Set up Environment Variables

Create a .env.local file in the project root:
CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-here
You’ll get your CLIENT_ID and CLIENT_SECRET after registering your app in Step 8. For now, create the file with placeholder values.

4. Add JTL UI Styles

Open app/globals.css and add the JTL Platform UI import at the top:
@import '@jtl-software/platform-ui-react/dist/main.css';
@source 'node_modules/@jtl-software/platform-ui-react/dist';

5. Build the Backend

Your backend handles two things: getting an access token from JTL (using client credentials) and verifying session tokens from the App Shell. You’ll build this in three parts:
  • An auth helper
  • A session verification utility
  • An API route

Auth Helper

Create lib/jtl-auth.ts:
const getAuthEndpoint = () => {
	return 'https://auth.jtl-cloud.com/oauth2/token';
};

const getApiBaseUrl = () => {
	return 'https://api.jtl-cloud.com';
};

export async function getJwt(): Promise<string> {
	const clientId = process.env.CLIENT_ID;
	const clientSecret = process.env.CLIENT_SECRET;

	if (!clientId || !clientSecret) {
		throw new Error(
			'CLIENT_ID and CLIENT_SECRET must be defined in .env.local',
		);
	}

	const authString = Buffer.from(`${clientId}:${clientSecret}`).toString(
		'base64',
	);

	const response = await fetch(getAuthEndpoint(), {
		method: 'POST',
		headers: {
			'Content-Type': 'application/x-www-form-urlencoded',
			Authorization: `Basic ${authString}`,
		},
		body: new URLSearchParams({
			grant_type: 'client_credentials',
		}),
	});

	const data = await response.json();

	if (response.ok) {
		return data.access_token;
	} else {
		throw new Error(
			`Failed to fetch JWT (${response.status}): ${data.error}`,
		);
	}
}

export { getApiBaseUrl };
What this does: Encodes your client credentials as Base64, sends a client_credentials grant to JTL’s auth endpoint, and returns an access token. This token is used server-side to verify session tokens and call JTL APIs.

Session Token Verification

Create lib/verify-session.ts:
import { importJWK, jwtVerify } from 'jose';
import { getJwt, getApiBaseUrl } from './jtl-auth';

export interface SessionTokenPayload {
	exp: number;
	userId: string;
	tenantId: string;
	tenantSlug: string;
	kid: string;
}

export async function verifySessionToken(
	sessionToken: string,
): Promise<SessionTokenPayload> {
	const jwt = await getJwt();
	const baseUrl = getApiBaseUrl();

	const response = await fetch(`${baseUrl}/account/.well-known/jwks.json`, {
		headers: {
			Authorization: `Bearer ${jwt}`,
		},
	});

	if (!response.ok) {
		throw new Error(`Failed to fetch JWKS (${response.status})`);
	}

	const jwks = await response.json();
	const key = jwks.keys[0];
	const publicKey = await importJWK(key, 'EdDSA');

	const { payload } = await jwtVerify(sessionToken, publicKey);
	return payload as unknown as SessionTokenPayload;
}
What this does: Fetches JTL’s public keys (JWKS), then uses them to verify the session token that came from AppBridge. The verified payload tells you which tenant (merchant) and user installed your app.
In production, you should select the correct key using the kid from the token header. This example uses the first key for simplicity.

Connect Tenant API Route

Create app/api/connect-tenant/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { verifySessionToken } from '@/lib/verify-session';

export async function POST(request: NextRequest) {
	try {
		const { sessionToken } = await request.json();

		if (!sessionToken) {
			return NextResponse.json(
				{ error: 'Session token is required' },
				{ status: 400 },
			);
		}

		const payload = await verifySessionToken(sessionToken);

		// In a real app, you'd store the tenant connection in your database here
		console.log('✅ Tenant connected:', payload);

		return NextResponse.json({
			success: true,
			tenantId: payload.tenantId,
			userId: payload.userId,
			tenantSlug: payload.tenantSlug,
		});
	} catch (error) {
		console.error('❌ Connection failed:', error);
		return NextResponse.json(
			{ error: 'Failed to verify session token' },
			{ status: 401 },
		);
	}
}
What this does: Receives the session token from your frontend, verifies it, and returns the tenant details. In a production app, this is where you’d store the tenant connection in your database. See the Tenant Mapping section for more information on managing tenants.

6. Build the Frontend

Your frontend has two kinds of pages, and they run in very different environments:
  • Pages that run inside JTL platform (/setup, /erp)
  • Standalone pages (/hub)
You’ll use a Next.js route group to scope the routes, so standalone pages can render on their own.
app/
├── (shell)/              # Route group: Pages that run inside JTL (wrapped with AppBridgeProvider)
│   ├── layout.tsx
│   ├── setup/
│   │   └── page.tsx
│   └── erp/
│       └── page.tsx
├── hub/
│   └── page.tsx          # Standalone, no AppBridge
Route groups in Next.js (folders wrapped in parentheses) organize routes without affecting the URL. (shell)/erp/page.tsx still resolves to /erp, not /shell/erp.
You’ll build this in five parts:
  • An AppBridge provider (handles the connection to JTL)
  • A shell layout that applies the provider to iframe routes
  • A setup page (shown when the merchant first installs your app)
  • An ERP page (shown when the merchant uses your app inside JTL)
  • A Hub page (shown when the merchant uses your app outside JTL Cloud).

AppBridge Provider

AppBridge is the SDK that handles communication between your app (running in an iframe) and the JTL App Shell (the host). It needs to be initialized client-side and made available to all your pages using React Context. Create components/AppBridgeProvider.tsx:
'use client';

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 default 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(() => {
		async function init() {
			try {
				// Dynamic import — AppBridge only works in the browser
				const { createAppBridge } =
					await import('@jtl-software/cloud-apps-core');
				const bridge = await createAppBridge();
				setAppBridge(bridge);

				// Get session token from the App Shell
				const sessionToken =
					await bridge.method.call('getSessionToken');

				// Verify it with your backend
				const res = await fetch('/api/connect-tenant', {
					method: 'POST',
					headers: { 'Content-Type': 'application/json' },
					body: JSON.stringify({ sessionToken }),
				});

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

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

		init();
	}, []);

	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>
	);
}
What this does: This handles the core integration flow. On mount, it:
  1. Dynamically imports createAppBridge to avoid SSR issues (AppBridge requires a browser environment)
  2. Creates the bridge, establishing the iframe with the App Shell communication channel
  3. Requests a session token from the App Shell via bridge.method.call('getSessionToken')
  4. Sends the token to your backend (/api/connect-tenant) for verification and tenant identification
  5. Stores the tenant ID in context so any child component can access it
All pages that need to communicate with the JTL platform (setup and ERP pages) can access the AppBridge instance and tenant information through the useAppBridge() hook. This avoids prop drilling and ensures a single shared connection state across your app.

Shell Layout

Create the route group layout at app/(shell)/layout.tsx. Next.js applies this layout to every page inside the (shell) group, giving them a session token and tenant ID automatically.
'use client';

import AppBridgeProvider from '@/components/AppBridgeProvider';

export default function ShellLayout({
	children,
}: {
	children: React.ReactNode;
}) {
	return <AppBridgeProvider>{children}</AppBridgeProvider>;
}

Setup Page

When a merchant installs your app, JTL loads the setupUrl from your manifest. This is where the merchant confirms the connection. Create app/(shell)/setup/page.tsx:
'use client';

import { useAppBridge } from '@/components/AppBridgeProvider';
import { Button, Box, Text } from '@jtl-software/platform-ui-react';

export default 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'>
				<Text type='h2' weight='bold' color='danger' as='h1'>
					Connection Failed
				</Text>
				<Text type='body' color='muted'>
					{error}
				</Text>
			</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. Click
					below to complete the setup.
				</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>
	);
}
What this does: By the time this page renders, the AppBridge provider has already handled the session token verification and tenant connection. The setup page just needs to call setupCompleted to tell the JTL Cloud the connection is done. The heavy lifting already happened in the provider.

ERP Page

This is the page merchants see when they open your app inside the ERP Portal. Create app/(shell)/erp/page.tsx:
'use client';

import { useAppBridge } from '@/components/AppBridgeProvider';
import { Box, Text } from '@jtl-software/platform-ui-react';

export default function ErpPage() {
	const { tenantId, error } = useAppBridge();

	if (error) {
		return (
			<Box className='p-8'>
				<Text type='h2' weight='bold' color='danger' as='h1'>
					Error
				</Text>
				<Box className='mt-2'>
					<Text type='body' color='muted'>
						{error}
					</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='mt-6'>
					<Box className='mb-3'>
						<Text type='h3' weight='semibold' as='h2'>
							Connected Tenant
						</Text>
					</Box>
					<Box className='bg-gray-50 rounded-lg p-4 font-mono text-sm'>
						<Text as='p'>
							<strong>Tenant ID:</strong> {tenantId}
						</Text>
					</Box>
					<Box className='mt-3'>
						<Text type='small' color='muted'>
							✅ Your app is connected and ready. Start building
							your UI here.
						</Text>
					</Box>
				</Box>
			)}
		</Box>
	);
}
What this does: Reads the tenantId from the AppBridge context and displays it. In production, this is where you’d build your actual UI like dashboards, settings panels, data views, or whatever your app does. The tenant ID tells you which merchant is using your app, so you can fetch and display their data.

Hub Page

The Hub page opens in a standalone browser tab when a merchant clicks your app card in the JTL Hub. It runs outside the JTL Hub, so there is no AppBridge, no session token, and no tenant context. Create app/hub/page.tsx:
'use client';

import {
	Card,
	CardHeader,
	CardTitle,
	CardDescription,
	CardContent,
	CardFooter,
	Text,
	Stack,
	Box,
	Button,
} from '@jtl-software/platform-ui-react';
import {
	Rocket,
	ExternalLink,
	MonitorSmartphone,
	Sparkles,
	ArrowUpRight,
} from 'lucide-react';

export default function HubPage() {
	return (
		<Box className='min-h-screen w-full flex items-center justify-center bg-[radial-gradient(ellipse_at_top,_theme(colors.slate.100),_theme(colors.slate.50))] p-4 sm:p-8 md:p-12'>
			<Card className='max-w-[560px] w-full shadow-xl rounded-3xl border border-slate-200/60 overflow-hidden bg-white'>
				<CardHeader className='items-center text-center gap-4 pt-10 pb-6 bg-gradient-to-b from-slate-50 to-white'>
					<Box className='relative flex items-center justify-center w-20 h-20 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/30'>
						<Rocket
							size={36}
							strokeWidth={1.75}
							className='text-white'
						/>
						<Box className='absolute -top-1 -right-1 flex items-center justify-center w-7 h-7 rounded-full bg-amber-400 shadow-md'>
							<Sparkles
								size={14}
								strokeWidth={2.5}
								className='text-white'
							/>
						</Box>
					</Box>
					<Box className='flex flex-col items-center gap-2'>
						<CardTitle className='text-2xl font-semibold tracking-tight'>
							Launched from JTL Cloud
						</CardTitle>
						<CardDescription className='text-center max-w-[420px] leading-relaxed'>
							This page opens when a merchant clicks your app card
							in the JTL Cloud. It runs in a full browser tab, not
							inside the JTL Cloud.
						</CardDescription>
					</Box>
				</CardHeader>

				<CardContent className='px-6 pb-6'>
					<Stack spacing='3' direction='column'>
						<Box className='group rounded-xl border border-slate-200 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50'>
							<Stack
								spacing='3'
								direction='row'
								itemAlign='start'
							>
								<Box className='flex items-center justify-center w-9 h-9 rounded-lg bg-indigo-100 text-indigo-600 shrink-0'>
									<ExternalLink size={18} strokeWidth={2} />
								</Box>
								<Box className='flex flex-col gap-1 min-w-0 flex-1'>
									<Box className='uppercase tracking-wider'>
										<Text
											type='xs'
											weight='semibold'
											color='muted'
										>
											Manifest mapping
										</Text>
									</Box>
									<Box className='break-all'>
										<Text type='inline-code'>
											capabilities.hub.appLauncher.redirectUrl
										</Text>
									</Box>
									<Text type='xs' color='muted'>
										Opens this page in a new browser tab
									</Text>
								</Box>
							</Stack>
						</Box>

						<Box className='group rounded-xl border border-slate-200 bg-slate-50/50 p-4 transition-colors hover:bg-slate-50'>
							<Stack
								spacing='3'
								direction='row'
								itemAlign='start'
							>
								<Box className='flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-100 text-emerald-600 shrink-0'>
									<MonitorSmartphone
										size={18}
										strokeWidth={2}
									/>
								</Box>
								<Box className='flex flex-col gap-1 min-w-0 flex-1'>
									<Box className='uppercase tracking-wider'>
										<Text
											type='xs'
											weight='semibold'
											color='muted'
										>
											How this differs
										</Text>
									</Box>
									<Box className='leading-relaxed'>
										<Text type='small' color='muted'>
											Unlike{' '}
											<Text type='inline-code'>/erp</Text>
											, this page runs outside the ERP.
											There is no AppBridge, no session
											token, and no tenant context. Use it
											for standalone features like
											dashboards, onboarding flows, or
											settings.
										</Text>
									</Box>
								</Box>
							</Stack>
						</Box>
					</Stack>
				</CardContent>

				<CardFooter className='flex-col items-stretch gap-3 px-6 pb-8 pt-2 border-t border-slate-100 bg-gradient-to-b from-white to-slate-50/50'>
					<Box className='flex flex-col gap-1 pt-4'>
						<Box className='uppercase tracking-wider'>
							<Text type='xs' weight='semibold' color='muted'>
								Next steps
							</Text>
						</Box>
						<Text type='small' color='muted'>
							Replace this page with your app's standalone
							experience.
						</Text>
					</Box>
					<a
						href='https://hub.jtl-cloud.com/'
						target='_blank'
						rel='noopener noreferrer'
						className='block'
					>
						<Button
							variant='default'
							fullWidth
							label='Open JTL Cloud Hub'
							icon={<ArrowUpRight size={16} strokeWidth={2} />}
							iconPosition='right'
						/>
					</a>
				</CardFooter>
			</Card>
		</Box>
	);
}

7. Create the Manifest

Create manifest.json in the root directory:
{
	"manifestVersion": "1.0.0",
	"version": "1.0.0",
	"name": {
		"short": "my-jtl-app",
		"full": "My JTL Cloud App"
	},
	"description": {
		"short": "A Cloud App built with Next.js",
		"full": "A sample Cloud App built from scratch using Next.js and TypeScript."
	},
	"defaultLocale": "en",
	"locales": {
		"en": {
			"key1": "My App"
		},
		"de-DE": {
			"key1": "Meine App"
		}
	},
	"icon": {
		"light": "https://hub.jtl-cloud.com/assets/image-placeholder.png",
		"dark": "https://hub.jtl-cloud.com/assets/image-placeholder.png"
	},
	"communication": {
		"supportUrl": "https://example.com/support",
		"guideUrl": "https://example.com/guide"
	},
	"legal": {
		"gdprRequestUrl": "https://example.com/gdpr/request",
		"gdprDeleteUrl": "https://example.com/gdpr/delete",
		"privacyUrl": "https://example.com/privacy",
		"termsOfUseUrl": "https://example.com/terms"
	},
	"lifecycle": {
		"setupUrl": "http://localhost:3000/setup",
		"connectUrl": "http://localhost:3000/api/connect-tenant",
		"disconnectUrl": "http://localhost:3000/api/disconnect-tenant"
	},
	"capabilities": {
		"hub": {
			"appLauncher": {
				"redirectUrl": "http://localhost:3000/hub",
				"closedPreviewUrl": "http://localhost:3000/closed-preview"
			}
		},
		"erp": {
			"headless": {
				"url": "http://localhost:3000/api/erp"
			},
			"menuItems": [
				{
					"id": "my-app-menu",
					"name": "My JTL App",
					"url": "http://localhost:3000/erp"
				}
			],
			"pane": [
				{
					"url": "http://localhost:3000/pane",
					"title": "My Pane",
					"context": "customers",
					"matchChildContext": true
				}
			]
		}
	}
}
What this does: The manifest tells JTL everything about your app. The name, description, where to load it, and what capabilities it has. The lifecycle.setupUrl points to your setup page, and capabilities.hub.appLauncher.redirectUrl points to your ERP page. The capabilities.hub.appLauncher.redirectUrl points to /hub, the standalone page you built in the previous step. The lifecycle.setupUrl and capabilities.erp.menuItems[].url point to the routes under (shell)/.
The localhost:3000 URLs are for local development only. When you deploy your app, you’ll update these to your production domain.

8. Register your App

Now that your code is ready, register it with JTL:
1

Open the Partner Portal

Go to Partner Portal and log in.
2

Create a New App

Click + Create. You’ll see a manifest editor with a pre-filled example.
3

Paste your Manifest

Replace the example manifest with the contents of your manifest.json file. Click Register app.Partner Portal app creation screenshot
4

Copy your Credentials

After registration, you’ll see your Client ID and Client Secret.
These are shown only once. Copy both values immediately and add them to your .env.local file. If you lose them, you’ll need to register a new app.
Update your .env.local with the real credentials:
CLIENT_ID=your-actual-client-id
CLIENT_SECRET=your-actual-client-secret

9. Run the App

npm run dev
Your app is now running at http://localhost:3000. Opening it directly in a browser will result in a blank page, since the app is meant to be rendered inside the JTL App Shell.

10. Install and Test in JTL Hub

1

Open JTL Hub

Go to JTL Hub and log in.
2

Find your App

Navigate to Apps in development. You should see your newly registered app.Discover apps
3

Install the App

Click the Install button. This loads your setup page inside the App Shell.Installation set up page on Iframe
4

Complete the Setup

Click the Install now button on your setup page. This triggers the full setup handshake between your app and JTLInstallation complete

11. Fetch Data from JTL-Wawi

Your app is running inside the App Shell and connected to a tenant. Now let’s pull real product data from the ERP. The JTL-Wawi uses GraphQL to query data. Create an API route to fetch items.

Items API Route

Create app/api/items/route.ts:
Every API request that needs to access tenant data needs an X-Tenant-ID header. You extract this value from the verified session token payload. See the Session Token Payload reference for details.
import { NextRequest, NextResponse } from 'next/server';
import { getJwt } from '@/lib/jtl-auth';
import { verifySessionToken } from '@/lib/verify-session';

export async function GET(request: NextRequest) {
	try {
		// Read the session token from the Authorization header
		const authHeader = request.headers.get('authorization');
		if (!authHeader?.startsWith('Bearer ')) {
			return NextResponse.json(
				{ error: 'Missing or invalid Authorization header' },
				{ status: 401 },
			);
		}
		const sessionToken = authHeader.slice(7);

		// Verify the session token and extract the tenant ID from the JWT payload
		const payload = await verifySessionToken(sessionToken);
		if (!payload?.tenantId) {
			return NextResponse.json(
				{ error: 'Invalid session token: could not extract tenant ID' },
				{ status: 401 },
			);
		}
		const tenantId = payload.tenantId;

		const accessToken = await getJwt();

		const response = await fetch(
			`https://api.jtl-cloud.com/erp/v2/graphql`,
			{
				method: 'POST',
				headers: {
					'Content-Type': 'application/json',
					Authorization: `Bearer ${accessToken}`,
					'X-Tenant-ID': tenantId,
				},
				body: JSON.stringify({
					operationName: 'GetERPItems',
					query: `
                        query GetERPItems {
                            QueryItems(first: 10) {
                                nodes { 
                                    id
                                    sku
                                    name
                                    notes
                                    basePriceUnit
                                }
                                totalCount
                            }
                        }
                    `,
				}),
			},
		);

		const { data } = await response.json();
		return NextResponse.json(data.QueryItems);
	} catch (error) {
		console.error('Failed to fetch items:', error);
		return NextResponse.json(
			{ error: 'Failed to fetch items' },
			{ status: 500 },
		);
	}
}
What this does: Creates a Next.js API route that authenticates with JTL using your client credentials, sends a GraphQL query to fetch the first 10 products, and returns the result.

Display Items in the ERP Page

Replace the contents of app/(shell)/erp/page.tsx:
'use client';

import { useAppBridge } from '@/components/AppBridgeProvider';
import { Box, Text } from '@jtl-software/platform-ui-react';
import { useCallback, useEffect, useState } from 'react';

interface Item {
	id: string;
	sku: string;
	name: string;
}

export default function ErpPage() {
	const { appBridge, tenantId } = useAppBridge();
	const [items, setItems] = useState<Item[]>([]);
	const [totalCount, setTotalCount] = useState(0);
	const [loading, setLoading] = useState(false);
	const [error, setError] = useState<string | null>(null);

	const fetchItems = useCallback(async () => {
		if (!appBridge) return;
		try {
			setLoading(true);
			const sessionToken =
				await appBridge.method.call<string>('getSessionToken');

			const res = await fetch('/api/items', {
				headers: {
					Authorization: `Bearer ${sessionToken}`,
				},
			});

			if (!res.ok) {
				throw new Error(`Request failed (${res.status})`);
			}

			const data = await res.json();
			setItems(data.nodes || []);
			setTotalCount(data.totalCount || 0);
		} catch (err) {
			console.error(String(err));
			setError(
				err instanceof Error ? err.message : 'Failed to load products',
			);
		} finally {
			setLoading(false);
		}
	}, [appBridge]);

	useEffect(() => {
		fetchItems();
	}, [fetchItems]);

	if (error) {
		return (
			<Box className='p-8'>
				<Text type='h2' weight='bold' color='danger' as='h1'>
					Error
				</Text>
				<Box className='mt-2'>
					<Text type='body' color='muted'>
						{error}
					</Text>
				</Box>
			</Box>
		);
	}

	return (
		<Box className='p-8'>
			<Box className='mb-4'>
				<Text type='h2' weight='bold'>
					My JTL Cloud App
				</Text>
			</Box>
			<Box className='mb-6'>
				<Text type='body' color='muted'>
					Connected to tenant:{' '}
					<code className='bg-gray-100 px-2 py-1 rounded'>
						{tenantId}
					</code>
				</Text>
			</Box>

			<Box className='mb-3'>
				<Text type='h3' weight='semibold' as='h2'>
					Products from Cloud-ERP
				</Text>
			</Box>

			{loading ? (
				<Text type='body' color='muted'>
					Loading products...
				</Text>
			) : items.length === 0 ? (
				<Text type='body' color='muted'>
					No products found. Make sure your test account has items in
					Cloud-ERP.
				</Text>
			) : (
				<>
					<Box className='mb-3'>
						<Text type='small' color='muted'>
							Showing {items.length} of {totalCount} products
						</Text>
					</Box>
					<table className='w-full text-left border-collapse'>
						<thead>
							<tr className='border-b border-gray-200'>
								<th className='py-5 pr-8 font-semibold'>sku</th>
								<th className='py-5 pr-8 font-semibold'>
									name
								</th>
							</tr>
						</thead>
						<tbody>
							{items.map((item) => (
								<tr
									key={item.id}
									className='border-b border-gray-200'
								>
									<td className='py-2 text-sm pr-8'>
										{item.sku}
									</td>
									<td className='py-2 text-sm pr-8'>
										{item.name}
									</td>
								</tr>
							))}
						</tbody>
					</table>
				</>
			)}
		</Box>
	);
}
What this does: On mount, the component requests a session token from the AppBridge, passes it to your backend as the Authorization header, and renders the returned products in a table. A sample response from the GraphQL API looks like this:
{
	"nodes": [
		{
			"id": "895d2d4d-4ed4-44c7-ac61-ebec01000000",
			"sku": "AR2016041-VKO",
			"name": "Men's T-shirt"
		},
		{
			"id": "895d2d4d-4ed4-44c7-ac61-ebec06000000",
			"sku": "AR2016041-002",
			"name": "Men's T-shirt orange S"
		},
		{
			"id": "895d2d4d-4ed4-44c7-ac61-ebec07000000",
			"sku": "AR2016041-001",
			"name": "Men's T-shirt blue S"
		}
	],
	"totalCount": 674
}
This is a minimal query to verify the connection. The GraphQL API supports filtering, pagination, and full-text search. Explore the full schema interactively in the GraphQL Playground, or read the Using Platform APIs guide for more patterns.

What Just Happened?

Your app runs inside the JTL App Shell (in an iframe). AppBridge handles communication. Your frontend requests a session token, sends it to your backend for verification, and the verified payload identifies the tenant. From there, your app can call the Cloud-ERP API. The project structure looks like this:
jtl-cloud-app/
├── app/
│   ├── (shell)/                 # Iframe-only routes (wrapped with AppBridgeProvider)
│   │   ├── layout.tsx
│   │   ├── setup/
│   │   │   └── page.tsx         # Setup page (install handshake)
│   │   └── erp/
│   │       └── page.tsx         # ERP page (app UI inside JTL)
│   ├── hub/
│   │   └── page.tsx             # Standalone Hub landing page
│   ├── api/
│   │   └── connect-tenant/
│   │       └── route.ts         # Verifies session tokens
│   ├── globals.css              # JTL UI + Tailwind styles
│   ├── layout.tsx               # Root layout
│   └── page.tsx                 # Default page (not used in JTL)
├── components/
│   └── AppBridgeProvider.tsx    # React Context provider for AppBridge
├── lib/
│   ├── jtl-auth.ts              # OAuth client credentials flow
│   └── verify-session.ts        # Session token verification via JWKS
├── public/
│   └── manifest.json            # App manifest for Partner Portal
├── .env.local                   # Your credentials (not committed)
└── package.json

Common Issues

AppBridge requires a browser environment. Make sure:
  • AppBridgeProvider uses dynamic import() and not a top-level import statement
  • The provider is only used in components marked with 'use client'
  • The layout.tsx wraps children in <AppBridgeProvider> as shown in the guide
The AppBridge provider can’t connect. Common causes:
  • Your app isn’t running inside the JTL App Shell (it won’t work in a regular browser tab)
  • Your app isn’t running (npm run dev)
  • The /api/connect-tenant route has an error — check the terminal for server logs
  • Your CLIENT_ID or CLIENT_SECRET in .env.local is wrong
Check your .env.local:
  • Are CLIENT_ID and CLIENT_SECRET correct?
  • Did you restart the dev server after changing .env.local?
  • Did you register the app in the Partner Portal? - Is the manifest valid JSON? - Check the Apps in development section, not the main app store
  • Is your local dev server running (npm run dev)? - Does the lifecycle.setupUrl in your manifest match your local URL (http://localhost:3000/setup)? - Check the browser console for CORS or mixed content errors
The icon URLs in your manifest must be publicly accessible hosted images. They cannot be local file paths or relative URLs.Valid:
  "icon": {
    "light": "https://i.ibb.co/RkWVbTsG/your-logo.png",
    "dark": "https://i.ibb.co/RkWVbTsG/your-logo.png"
  }
Invalid:
  "icon": {
    "light": "./assets/logo.png",  // ❌ Local path
    "dark": "/images/logo.png"     // ❌ Relative URL
  }
Your API request is missing required headers. Verify you’re sending:
  • Authorization: Bearer <accessToken>
  • X-Tenant-ID: <tenantId> (from session token payload)
Make sure the session token is still valid and hasn’t expired.
If you get { "data": null } or empty results:
  • Verify the tenantId is correct (check what’s in your session token)
  • Confirm you have data in your Wawi for the query you’re making
  • Check that your GraphQL query syntax is valid (no missing brackets or commas)

What’s Next?

You’ve built a working JTL Cloud App from scratch. Here’s where to go deeper:

Test your App

Validate your app in the sandbox with test data.

Using Platform APIs

Use the JTL-Wawi REST and GraphQL APIs, handle responses, and work with tenant-scoped data.

GraphQL Playground

Try queries and mutations interactively against your ERP instance.

App Shell & UI

Learn how to integrate deeper with the JTL UI and App Shell.

Submit to the App Store

Deploy your app and publish it for merchants.