Skip to main content
This guide outlines the essential patterns for building production-ready Cloud Apps. Each section focuses on a specific part of your app’s architecture, with practical guidance and code examples where relevant.

AppBridge Initialization

How you initialize the AppBridge determines whether your app starts reliably or fails intermittently with race conditions.

Initialize Before Rendering

Always create the AppBridge instance before your React app renders. If you initialize it inside a component (e.g., in useEffect), child components may try to access the bridge before it’s ready.
// Correct: initialize before render
import { createAppBridge } from '@jtl-software/cloud-apps-core';

createAppBridge().then((appBridge) => {
	createRoot(document.getElementById('root')!).render(
		<App appBridge={appBridge} />,
	);
});
// Incorrect: initializing inside a component
function App() {
	const [bridge, setBridge] = useState(null);

	useEffect(() => {
		// This causes a race condition: children render before the bridge is ready
		createAppBridge().then(setBridge);
	}, []);

	return <Dashboard appBridge={bridge} />;
}

Use Dynamic Imports for SSR Frameworks

If you’re using Next.js or another framework with server-side rendering, the AppBridge SDK fails on the server because it requires a browser environment. Use a client-side provider with dynamic imports.
'use client';

useEffect(() => {
	async function init() {
		const { createAppBridge } =
			await import('@jtl-software/cloud-apps-core');
		const bridge = await createAppBridge();
		// Store in state or context
	}
	init();
}, []);
What this does: Delays the import until the component runs in the browser, preventing window is not defined errors during server rendering. See the From Scratch quickstart for the full provider pattern.

Handle Initialization Failures

The AppBridge can fail to initialize if your app isn’t running inside the App Shell (e.g., during local development in a regular browser tab). Always catch errors and display a helpful message.
try {
	const bridge = await createAppBridge();
	setAppBridge(bridge);
} catch (err) {
	setError(
		'Could not connect to JTL. Make sure the app is running inside the App Shell.',
	);
}

Authentication

Token management is the most common source of production issues. These patterns prevent the majority of auth-related failures.

Cache Access Tokens

Access tokens are valid for approximately one hour. Requesting a new token on every API call wastes time and puts unnecessary load on the auth server. Cache the token in memory and refresh it before expiry.
let cachedToken: string | null = null;
let tokenExpiresAt = 0;
const BUFFER_MS = 60_000; // refresh 60 seconds early

export async function getCachedAccessToken(): Promise<string> {
	if (cachedToken && Date.now() < tokenExpiresAt - BUFFER_MS) {
		return cachedToken;
	}

	const token = await getAccessToken();
	const payload = JSON.parse(
		Buffer.from(token.split('.')[1], 'base64').toString(),
	);

	cachedToken = token;
	tokenExpiresAt = payload.exp * 1000;
	return cachedToken;
}
What this does: Reuses the cached token until 60 seconds before expiry, then fetches a fresh one. The buffer prevents failures from tokens expiring mid-request.

Retry on 401

Even with caching, tokens can expire unexpectedly (server clock drift, cache invalidation). Retry once with a fresh token before surfacing the error.
async function authenticatedFetch(url: string, tenantId: string) {
	let token = await getCachedAccessToken();

	let response = await fetch(url, {
		headers: {
			Authorization: `Bearer ${token}`,
			'X-Tenant-Id': tenantId,
		},
	});

	if (response.status === 401) {
		cachedToken = null;
		token = await getCachedAccessToken();

		response = await fetch(url, {
			headers: {
				Authorization: `Bearer ${token}`,
				'X-Tenant-ID': tenantId,
			},
		});
	}

	return response;
}

Never Expose Credentials to the Frontend

Your CLIENT_SECRET must stay on the server. Never include it in frontend code, environment variables prefixed with NEXT_PUBLIC_, or client-side bundles. The frontend authenticates through the AppBridge session token, not client credentials.

Refresh Session Tokens before Sensitive Operations

Session tokens from the AppBridge are short-lived. Request a fresh one before operations that require verified identity, rather than reusing a token obtained minutes ago.
// Before each sensitive operation
const freshToken = await appBridge.method.call('getSessionToken');
await fetch('/api/connect-tenant', {
	method: 'POST',
	headers: { 'Content-Type': 'application/json' },
	body: JSON.stringify({ sessionToken: freshToken }),
});

API Usage

Efficient API usage keeps your app fast and prevents rate limiting.

Request Only the Fields You Need

GraphQL lets you specify exactly which fields to return. Avoid requesting fields your UI doesn’t display. Smaller payloads mean faster responses and less bandwidth.
# Too broad: fetching everything
query {
	QueryItems(first: 50) {
		nodes {
			id
			sku
			name
			gtin
			upc
			defaultAsin
			salesPriceNet
			averagePurchasePriceNet
			stockTotal
			stockInOrders
			manufacturerId
			manufacturerName
			productGroupId
			productGroupName
			taxClassId
			taxClassName
			notes
			basePriceUnit
		}
	}
}

# Better: only what the UI needs
query {
	QueryItems(first: 50) {
		nodes {
			id
			sku
			name
			salesPriceNet
			stockTotal
		}
	}
}

Paginate Large Datasets

Never fetch all records at once. Use cursor-based pagination with a reasonable page size (10-50 items). Fetch the next page only when the user scrolls or clicks “Load more.”
async function fetchAllItems(tenantId: string): Promise<Item[]> {
	const items: Item[] = [];
	let cursor: string | null = null;
	let hasMore = true;

	while (hasMore) {
		const data = await query(tenantId, 'GetERPItems', ITEMS_QUERY, {
			first: 50,
			after: cursor,
		});

		items.push(...data.QueryItems.nodes);
		hasMore = data.QueryItems.pageInfo.hasNextPage;
		cursor = data.QueryItems.pageInfo.endCursor;
	}

	return items;
}
What this does: Fetches items in pages of 50 until all records are retrieved. For background sync jobs this is fine, but for UI-driven fetching, load pages on demand instead of fetching everything upfront.

Handle GraphQL Errors in the Response Body

GraphQL returns errors inside the JSON body, not as HTTP status codes. A 200 OK response can still contain errors. Always check both data and errors.
const { data, errors } = await response.json();

if (errors?.length) {
	console.error('GraphQL errors:', errors);
	throw new Error(errors[0].message);
}

Error Handling

Consistent error handling prevents your app from crashing and gives merchants useful feedback when things go wrong.

Wrap API Calls in try/catch

Every API call can fail. Network issues, expired tokens, rate limits, and server errors all produce exceptions. Catch them and provide meaningful error states.
async function loadItems(tenantId: string) {
	try {
		setLoading(true);
		setError(null);
		const data = await fetchItems(tenantId);
		setItems(data.nodes);
	} catch (err) {
		console.error('Failed to load items:', err);
		setError('Could not load products. Please try again.');
	} finally {
		setLoading(false);
	}
}

Distinguish Error Types

Different errors require different responses. A network timeout should be retried. A 403 should prompt the merchant to check permissions. A validation error should highlight the problematic field.
if (response.status === 401) {
	// Token expired: refresh and retry
} else if (response.status === 403) {
	// Missing scopes: show a message about permissions
} else if (response.status === 429) {
	// Rate limited: wait and retry with backoff
} else if (response.status >= 500) {
	// Server error: retry with backoff, then show an error
}

Show Loading and Error States

Never leave the merchant looking at a blank screen. Show a loading indicator while data is being fetched, and a clear error message if something fails.
if (loading) return <p>Loading products...</p>;
if (error) return <p>{error}</p>;
if (items.length === 0) return <p>No products found.</p>;

return <ItemTable items={items} />;

Security

These practices protect both your app and the merchants who use it.

Verify Session Tokens on the Backend

Never trust a session token without verifying it. The frontend can be manipulated. Your backend should verify every session token against JTL’s JWKS before using the tenant ID from it.

Validate Tenant ID on Every Request

When your frontend sends a tenant ID to your backend, verify it matches the tenant ID in the verified session token. This prevents one merchant from accessing another merchant’s data.
const session = await verifySessionToken(sessionToken);

if (session.tenantId !== requestedTenantId) {
	return NextResponse.json({ error: 'Tenant mismatch' }, { status: 403 });
}

Use HTTPS in Production

All production URLs in your manifest (setupUrl, connectUrl, disconnectUrl, menu item URLs, pane URLs) must use HTTPS. HTTP is acceptable for localhost during development only.

Store Credentials Securely

Keep your CLIENT_SECRET in environment variables. Never commit .env files to version control. Use a secrets manager (AWS Secrets Manager, Vault, Doppler) for production deployments.

Performance

Fast apps get more installs and fewer support tickets.

Cache JWKS Responses

JTL’s public keys change infrequently. Fetching them on every session token verification adds latency. Cache the JWKS response and refresh it only when verification fails with a key mismatch.
let jwksCache: JsonWebKeySet | null = null;
let jwksCacheExpiry = 0;
const JWKS_TTL = 3600_000; // 1 hour

async function getJwks(): Promise<JsonWebKeySet> {
	if (jwksCache && Date.now() < jwksCacheExpiry) {
		return jwksCache;
	}

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

	jwksCache = await response.json();
	jwksCacheExpiry = Date.now() + JWKS_TTL;
	return jwksCache;
}
What this does: Caches the JWKS for one hour and reuses it across all session token verifications. If a token fails verification, you can clear the cache and retry with fresh keys before returning an error.

Minimize Frontend Bundle Size

Use dynamic imports for the AppBridge SDK and any heavy dependencies. This reduces your initial page load, which matters because your app loads inside an iframe that already has the full ERP UI rendered around it.
// Dynamic import: loaded only when needed
const { createAppBridge } = await import('@jtl-software/cloud-apps-core');

Deployment Checklist

Before submitting your app to the App Store, verify each item on this list.
1

Update manifest URLs

Replace all localhost URLs in your manifest with production HTTPS URLs. This includes setupUrl, connectUrl, disconnectUrl, redirectUrl, menu item URLs, and pane URLs.
2

Verify credentials

Confirm your CLIENT_ID and CLIENT_SECRET are set in your production environment. Test the token exchange from your deployed server.
3

Test the full install flow

Walk through the complete flow in before deploying: install from JTL Cloud, complete setup, verify data loads, test all features, then uninstall and confirm cleanup.
4

Review error handling

Confirm your app handles token expiry, network failures, and empty data states without crashing or showing blank screens.
5

Validate icon URLs

Make sure your manifest’s icon.light and icon.dark point to publicly accessible HTTPS URLs, not local paths.
6

Test uninstall/reinstall

Uninstall your app and reinstall it. Verify that the setup flow works correctly a second time and any previously saved data is handled gracefully.
--- ## What’s Next

Submit to the App Store

Deploy your app and publish it for merchants.

Architecture Overview

Review the full architecture of JTL Cloud Apps.

Error Handling

In-depth error format reference and retry strategies.