Build a JTL Cloud App from scratch using Next.js. Understand every piece of the architecture.
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 minutesWhat 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
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.
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.
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:
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.
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.
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.
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 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:
What this does: This handles the core integration flow. On mount, it:
Dynamically importscreateAppBridge to avoid SSR issues (AppBridge requires a browser environment)
Creates the bridge, establishing the iframe with the App Shell communication channel
Requests a session token from the App Shell via bridge.method.call('getSessionToken')
Sends the token to your backend (/api/connect-tenant) for verification and tenant identification
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.
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>;}
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.
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.
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> );}
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.
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.
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.
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.
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:
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.
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:
'AppBridge is not defined' or 'window is not defined'
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
Spinner stuck on 'Connecting to JTL Platform...'
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
Session token verification fails (401)
Check your .env.local:
Are CLIENT_ID and CLIENT_SECRET correct?
Did you restart the dev server after changing .env.local?
App doesn't appear in JTL Hub
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
Setup page doesn't load in the App Shell
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
App icon not displaying or Invalid Icon-URL error
The icon URLs in your manifest must be publicly accessible hosted images. They cannot be local file paths or relative URLs.Valid: