3: Single-click authentication

In this exercise, you move into “MULTITENANT” mode, which enables the full single-click app flow. In this production-intended mode, every request is scoped to a store through the /store/[storeHash] route segment, and each store’s access token is obtained during install and looked up per request from durable storage instead of one hard-coded value.

Because the aim of this exercise is to support the full single-click app flow, there a few added layers you’ll be dealing with:

  • A remote tunnel must be used to expose your local app over a public HTTPS URL.
  • Your app must be registered in the BigCommerce Developer Portal.
  • A storage layer must be implemented to save store API tokens and user information. You’ll be using a simple SQLite data file.

See the project repository documentation for more details on the setup process.

Expose localhost over HTTPS

Make sure you’re still running your local development server with pnpm dev.

BigCommerce needs a public, fully-qualified HTTPS URL for both the server-to-server callbacks and the control panel iframe. The following are three good options:

  • VS Code port forwarding — Built into VS Code and free with a GitHub account.
  • GitHub Codespaces — For this option, push your code into your own GitHub repository and use the “Create codespace” feature within the Code dropdown to start a remote environment. You’ll need to follow the project’s initial setup (installing dependencies, setting up .env.local, and running pnpm run dev) in the remote environment. Forwarding a port in a codespace works the same as in VS Code.
  • ngrok — A dedicated tunneling tool. A free account is required for a usable session length.

The project repository documentation includes steps for each of these options.

If VS Code or Cursor is your IDE of choice, the port forwarding option has the benefit of using a consistent remote URL each time you start the tunnel. The prerequisites for utilizing this option include:

In VS Code or Cursor, navigate to the Ports view and “Forward a Port”.

VS Code port forwarding

If you haven’t connected VS Code with GitHub before, you’ll be prompted to sign in. Enter the port to forward (3000, or the port in your dev server output).

As a critical final step, you must set the forwarded port’s visibility to Public! Right-click on the forwarded port and select “Port Visibility” -> “Public”.

VS Code port visibility

Once you have one of the remote tunnel options running, browse to the remote URL to confirm your app is successfully served. (In VS Code, click the globe icon on the forwarded port.)

If you’re using ngrok, a new URL will be generated every time you restart the tunnel. When the URL changes, make sure to update the value in two places described in the following exercise:

  • APP_ORIGIN in .env.local (restart the dev server)
  • The callback URLs in the Developer Portal

Make sure to capture the public HTTPS base URL for use in the following steps.

Register the app in the Developer Portal

App registration happens in the Developer Portal. Creating an account there is free and doesn’t require a partnership — a partnership is only needed to publish to the marketplace. See Beginning development for the broader prerequisites and Managing apps in Dev Portal for a tour of the app profile. You’ll also need a store whose control panel you can sign into as a user with permission to install apps.

Make sure the sign into the Developer Portal using the same account that has owner permissions for your sandbox store.

  1. Use the “Create New” button in the Developer Portal.

  2. Copy the Client ID and Client Secret for use in the next step.

  3. On the Technical tab of your new app, set the callback URLs, substituting your tunnel URL for <APP_ORIGIN>:

    CallbackURL
    Auth<APP_ORIGIN>/api/app/auth
    Load<APP_ORIGIN>/api/app/load
    Uninstall<APP_ORIGIN>/api/app/uninstall
    Remove User<APP_ORIGIN>/api/app/remove_user

    These map to the four route handlers under src/app/api/app/. The Auth callback URL must match APP_ORIGIN exactly — the app sends it back to BigCommerce as the OAuth redirect_uri during the token exchange, and BigCommerce rejects a mismatch.

Callback URLs in the Developer Portal

Note that you’ll only be implementing the Auth and Load callbacks in this exercise. The full example app implements all four, and you’ll have a chance to examine the implementations of Uninstall and Remove User at the end of the tutorial.

  1. On the Scopes tab, enable:

    • Customers: modify
    • Marketing: modify
    • Channel Settings: read-only
    • Channel Listings: read-only
    • Information & Settings: read-only
    • App Extensions: manage

Keep the app in draft status. A draft app is installable from the control panel of any store your Developer Portal account owns, which is all you need for this tutorial.

Set environment variables

Set the following in .env.local (and .env.example) to prepare for all steps in this exercise, then restart the dev server.

1DATA_MODE=MULTITENANT
2
3BIGCOMMERCE_CLIENT_ID=<your-app-client-id>
4BIGCOMMERCE_CLIENT_SECRET=<your-app-client-secret>
5APP_ORIGIN=<your-public-https-tunnel-url>
6CREDENTIALS_ENCRYPTION_KEY=<encryption-key>
7CREDENTIALS_STORE_DRIVER=SQLITE

Generate a value for CREDENTIALS_ENCRYPTION_KEY with the following command:

$openssl rand -base64 32

CREDENTIALS_ENCRYPTION_KEY encrypts stored store access tokens at rest.

If you change CREDENTIALS_ENCRYPTION_KEY after storing tokens, previously stored tokens can no longer be decrypted.

After changing DATA_MODE to MULTITENANT, when browsing to the root URL of your app, you’ll now notice an “Unauthorized” error. In this production mode, only /store/<store-hash>/... URLs are allowed. You’ll no longer be browsing to your app directly.

Now that your app is running through a remote tunnel and you’ve configured your app in the Developer Portal, you’re ready to build the appropriate authentication flow to embed a single-click app in the BigCommerce control panel.

Build the SQLite credentials store driver

The first thing you’ll need is a place to store the API tokens negotiated during the single-click install process. The initial storage implementation in this app will be via SQLite; configuring an external database won’t be required.

1

Ignore the SQLite file

The file data/credentials.sqlite will be created the first time your app writes to SQLite. These data files shouldn’t be tracked in your project’s version control.

Update .gitignore to exclude *.sqlite files.

.gitignore
# local sqlite credentials store
/data/*.sqlite*
...
2

Define the credentials store schema

Replace the contents of src/lib/credentials-store/sqlite-driver/schema.ts.

sqlite-driver/schema.ts
export const CREATE_CREDENTIALS_STORE_SCHEMA = `
CREATE TABLE IF NOT EXISTS stores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
store_hash TEXT NOT NULL UNIQUE,
access_token TEXT NOT NULL,
scope TEXT NOT NULL,
admin_user_id INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
user_id INTEGER PRIMARY KEY,
email TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS store_users (
store_hash TEXT NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (store_hash, user_id)
);
`;

This schema defines tables for information about stores where the app is installed, users that have logged into the app, and the store/user relationship. The schema will be run whenever the app connects to SQLite.

3

Build the SQLite driver implementation

This is the most verbose step in this exercise, handling the grunt work of the database reads and writes.

Replace the contents of src/lib/credentials-store/sqlite-driver/sqlite-credentials-store.ts.

sqlite-credentials-store.ts
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { CREATE_CREDENTIALS_STORE_SCHEMA } from "@/lib/credentials-store/sqlite-driver/schema";
import { decrypt, encrypt } from "@/lib/credentials-store/encryption";
import { CredentialsStore, StoreRecord, StoreUserRecord, UserRecord } from "@/lib/credentials-store/types";
import { AppError } from "@/lib/errors/app-error";
import { logError } from "@/lib/errors/logger";
function withDatabaseErrorHandling<T>(context: string, run: () => T): T {
try {
return run();
} catch (error) {
logError(`SqliteCredentialsStore: ${context}`, error);
throw new AppError("DATABASE", "A database error occurred.", { cause: error });
}
}
const DEFAULT_DB_PATH = "./data/credentials.sqlite";
function getDbPath(): string {
return process.env.CREDENTIALS_SQLITE_PATH ?? DEFAULT_DB_PATH;
}
function openDatabase(path: string): DatabaseSync {
mkdirSync(dirname(path), { recursive: true });
const db = new DatabaseSync(path);
db.exec(CREATE_CREDENTIALS_STORE_SCHEMA);
return db;
}
interface StoreTokenRow {
access_token: string;
}
interface UserIdRow {
user_id: number;
}
interface CountRow {
c: number;
}
interface ExistsRow {
found: number;
}
export class SqliteCredentialsStore implements CredentialsStore {
private readonly db: DatabaseSync;
constructor(path: string = getDbPath()) {
this.db = withDatabaseErrorHandling("open", () => openDatabase(path));
}
async setStore(store: StoreRecord): Promise<void> {
withDatabaseErrorHandling("setStore", () => {
this.db
.prepare(
`INSERT INTO stores (store_hash, access_token, scope, admin_user_id)
VALUES (?, ?, ?, ?)
ON CONFLICT(store_hash) DO UPDATE SET
access_token = excluded.access_token,
scope = excluded.scope,
admin_user_id = excluded.admin_user_id`,
)
.run(store.storeHash, encrypt(store.accessToken), store.scope, store.adminUserId);
});
}
async setUser(user: UserRecord): Promise<void> {
withDatabaseErrorHandling("setUser", () => {
this.db
.prepare(
`INSERT INTO users (user_id, email)
VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET
email = excluded.email`,
)
.run(user.userId, user.email);
});
}
async setStoreUser(storeUser: StoreUserRecord): Promise<void> {
withDatabaseErrorHandling("setStoreUser", () => {
this.db
.prepare(
`INSERT INTO store_users (store_hash, user_id)
VALUES (?, ?)
ON CONFLICT(store_hash, user_id) DO NOTHING`,
)
.run(storeUser.storeHash, storeUser.userId);
});
}
async getStoreToken(storeHash: string): Promise<string | undefined> {
return withDatabaseErrorHandling("getStoreToken", () => {
const row = this.db.prepare("SELECT access_token FROM stores WHERE store_hash = ?").get(storeHash) as unknown as
| StoreTokenRow
| undefined;
return row ? decrypt(row.access_token) : undefined;
});
}
async isStoreUserLinked(storeHash: string, userId: number): Promise<boolean> {
return withDatabaseErrorHandling("isStoreUserLinked", () => {
const row = this.db
.prepare("SELECT 1 as found FROM store_users WHERE store_hash = ? AND user_id = ?")
.get(storeHash, userId) as unknown as ExistsRow | undefined;
return row !== undefined;
});
}
async deleteStore(storeHash: string): Promise<void> {
withDatabaseErrorHandling("deleteStore", () => {
this.db.exec("BEGIN TRANSACTION");
try {
const affectedUserIds = (
this.db.prepare("SELECT user_id FROM store_users WHERE store_hash = ?").all(storeHash) as unknown as UserIdRow[]
).map((row) => row.user_id);
this.db.prepare("DELETE FROM store_users WHERE store_hash = ?").run(storeHash);
this.db.prepare("DELETE FROM stores WHERE store_hash = ?").run(storeHash);
const countRemainingStoreUsersStmt = this.db.prepare("SELECT COUNT(*) as c FROM store_users WHERE user_id = ?");
const deleteUserStmt = this.db.prepare("DELETE FROM users WHERE user_id = ?");
for (const userId of affectedUserIds) {
const { c } = countRemainingStoreUsersStmt.get(userId) as unknown as CountRow;
if (c === 0) {
deleteUserStmt.run(userId);
}
}
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
});
}
async deleteUser(storeHash: string, userId: number): Promise<void> {
withDatabaseErrorHandling("deleteUser", () => {
this.db.exec("BEGIN TRANSACTION");
try {
this.db.prepare("DELETE FROM store_users WHERE store_hash = ? AND user_id = ?").run(storeHash, userId);
const { c } = this.db.prepare("SELECT COUNT(*) as c FROM store_users WHERE user_id = ?").get(userId) as unknown as CountRow;
if (c === 0) {
this.db.prepare("DELETE FROM users WHERE user_id = ?").run(userId);
}
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
});
}
}

This driver is a local-development choice only. node:sqlite gives synchronous, in-process access to one file on disk, which can’t be shared across the multiple instances a real deployment runs. Every method still returns a Promise to satisfy the shared CredentialsStore interface, even though the work underneath happens synchronously.

The SqliteCredentialsStore class is instantiated, with the database connection being opened and schema being created in the constructor. Note the class’s support not just for upserting (ON CONFLICT ... DO UPDATE) into the various tables, but also for fetching a store’s token, checking if a user exists on a store, and deleting all records related to a store.

4

Create the credentials store accessor

Similar to the controller function that selects the appropriate API client, you’ll now build out a function to select the credentials storage driver (based on the CREDENTIALS_STORE_DRIVER environment variable).

Replace the contents of src/lib/credentials-store/get-credentials-store.ts.

get-credentials-store.ts
import { SqliteCredentialsStore } from "@/lib/credentials-store/sqlite-driver/sqlite-credentials-store";
import { CredentialsStore, CredentialsStoreDriver } from "@/lib/credentials-store/types";
const VALID_DRIVERS: CredentialsStoreDriver[] = ["SQLITE"];
const DEFAULT_DRIVER: CredentialsStoreDriver = "SQLITE";
function getConfiguredDriver(): CredentialsStoreDriver {
const configuredDriver = process.env.CREDENTIALS_STORE_DRIVER?.toUpperCase();
return VALID_DRIVERS.includes(configuredDriver as CredentialsStoreDriver)
? (configuredDriver as CredentialsStoreDriver)
: DEFAULT_DRIVER;
}
function getConfiguredCredentialsStore(): CredentialsStore {
switch (getConfiguredDriver()) {
case "SQLITE":
default:
return new SqliteCredentialsStore();
}
}
export function getCredentialsStore(): CredentialsStore {
return getConfiguredCredentialsStore();
}
5

Look up the store token through the credentials store

Recall the function resolveApiToken, used by the API client controller. Currently, this supports only “MOCK” and “STATIC” modes. Now that you’ve implemented storage for API tokens, you can update this function to support looking up a token based on the store hash.

Update src/lib/bc-api-client/resolve-store-credentials.ts.

resolve-store-credentials.ts
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";
import { getDataMode } from "@/lib/bc-api-client/data-mode";
...
export async function resolveApiToken(storeHash: string | undefined): Promise<string | undefined> {
if (getDataMode() === "STATIC") {
return process.env.STATIC_STORE_TOKEN;
}
if (!storeHash) {
return undefined;
}
return getCredentialsStore().getStoreToken(storeHash);
}

The critical storage requirements are now handled in the application, and you’re ready to implement the single-click authentication flow.

Example code

Implement the install callback

When registering your app in the Developer Portal, you set the install callback URL to /api/app/auth. This is the route that BigCommerce will call when a user initiates a request to install the app. In this step, you’ll implement this endpoint.

1

Implement the OAuth token exchange

BigCommerce sends a payload including an OAuth code to the install callback URL in your app. The critical function your callback must perform is to make a request to BigCommerce to exchange this code for a permanent, store-scoped API token. Implement a utility function for performing this exchange.

Replace the contents of src/lib/bc-auth/exchange-code-for-token.ts.

exchange-code-for-token.ts
import { z } from "zod";
import { TokenExchangeFailedError } from "@/lib/bc-auth/errors";
const BC_LOGIN_URL = "https://login.bigcommerce.com";
const tokenResponseSchema = z.object({
access_token: z.string(),
scope: z.string(),
user: z.object({
id: z.number(),
username: z.string(),
email: z.string(),
}),
context: z.string(),
account_uuid: z.string(),
});
export type TokenResponse = z.infer<typeof tokenResponseSchema>;
export interface ExchangeCodeParams {
code: string;
context: string;
scope: string;
redirectUri: string;
}
export async function exchangeCodeForToken(params: ExchangeCodeParams): Promise<TokenResponse> {
const clientId = process.env.BIGCOMMERCE_CLIENT_ID;
const clientSecret = process.env.BIGCOMMERCE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("BIGCOMMERCE_CLIENT_ID and BIGCOMMERCE_CLIENT_SECRET must be set to exchange an auth code.");
}
let response: Response;
try {
response = await fetch(`${BC_LOGIN_URL}/oauth2/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code: params.code,
context: params.context,
scope: params.scope,
grant_type: "authorization_code",
redirect_uri: params.redirectUri,
}),
});
} catch (error) {
throw new TokenExchangeFailedError({ cause: error });
}
if (!response.ok) {
throw new TokenExchangeFailedError({
cause: `BigCommerce token exchange failed with status ${response.status}: ${(await response.text()).slice(0, 500)}`,
});
}
try {
return tokenResponseSchema.parse(await response.json());
} catch (error) {
throw new TokenExchangeFailedError({ cause: error });
}
}

Note the use of zod, a popular library that simplifies validation against a schema. This function utilizes it to ensure the token response from BigCommerce is in the expected format.

2

Implement the install action with storage

The next utility function will encapsulate the logic for installing the app on a store, including performing the token exchange and persisting store and user records (including the API token) to storage.

Replace the contents of src/lib/bc-auth/install-store.ts.

install-store.ts
import { exchangeCodeForToken } from "@/lib/bc-auth/exchange-code-for-token";
import { InstallSaveFailedError } from "@/lib/bc-auth/errors";
import { parseStoreHash } from "@/lib/bc-auth/verify-signed-payload";
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";
import { logError } from "@/lib/errors/logger";
export interface InstallStoreParams {
code: string;
context: string;
scope: string;
redirectUri: string;
}
export interface InstallStoreResult {
storeHash: string;
accessToken: string;
}
export async function installStore(params: InstallStoreParams): Promise<InstallStoreResult> {
const tokenResponse = await exchangeCodeForToken(params);
const storeHash = parseStoreHash(tokenResponse.context);
const credentialsStore = getCredentialsStore();
try {
await credentialsStore.setUser({
userId: tokenResponse.user.id,
email: tokenResponse.user.email,
});
await credentialsStore.setStore({
storeHash,
accessToken: tokenResponse.access_token,
scope: tokenResponse.scope,
adminUserId: tokenResponse.user.id,
});
await credentialsStore.setStoreUser({ storeHash, userId: tokenResponse.user.id });
} catch (error) {
logError(`installStore: store "${storeHash}"`, error);
throw new InstallSaveFailedError({ cause: error });
}
return { storeHash, accessToken: tokenResponse.access_token };
}

The data expected in the params (code, context, scope, and redirectUri) aligns with the params BigCommerce includes in the auth payload.

The API token is the key piece of data being stored here, but the app also records information about the specific user. This is the foundation for multi-user support, recommended for all single-click apps. Tracking per-user information serves two important purposes:

  • The store/user record will be used for authentication. Removing this record when an admin revokes a user’s access will ensure no stale browser sessions continue to allow access.
  • The app may choose to implement granular permissions for specific actions, exposing an interface to manage each user’s individual permissions.
3

Wire up the install route handler

Now that the key logic is in place, implement the API route handler itself to handle the BigCommerce auth callback.

Replace the contents of src/app/api/app/auth/route.ts.

app/auth/route.ts
import { NextRequest, NextResponse } from "next/server";
import { installStore } from "@/lib/bc-auth/install-store";
import { InstallSaveFailedError, TokenExchangeFailedError } from "@/lib/bc-auth/errors";
import { getAppErrorUrl } from "@/lib/bc-auth/app-error-reason";
import { getAbsoluteAppUrl } from "@/lib/routing/app-url";
import { logError } from "@/lib/errors/logger";
export async function GET(request: NextRequest): Promise<NextResponse> {
const code = request.nextUrl.searchParams.get("code");
const context = request.nextUrl.searchParams.get("context");
const scope = request.nextUrl.searchParams.get("scope");
if (!code || !context || !scope) {
logError("GET /api/app/auth", new Error("code, context, and scope are required."));
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INSTALL_FAILED")));
}
let storeHash: string;
try {
({ storeHash } = await installStore({
code,
context,
scope,
redirectUri: getAbsoluteAppUrl(undefined, "/api/app/auth"),
}));
} catch (error) {
logError("GET /api/app/auth: installStore", error);
if (error instanceof TokenExchangeFailedError) {
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("TOKEN_EXCHANGE_FAILED")));
}
if (error instanceof InstallSaveFailedError) {
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INSTALL_SAVE_FAILED")));
}
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INSTALL_FAILED")));
}
return NextResponse.redirect(getAbsoluteAppUrl(storeHash, "/"));
}

The API route is letting the previously created function handle the heavy lifting and takes care of redirecting the user to the home page of the app after a successful install.

getAbsoluteAppUrl handles generating a fully qualified URL using the env var APP_ORIGIN. Note the use of the storeHash in the home page redirect, resulting in a URL path like /store/<store-hash>/. All URLs generated within the app for actions and navigation will use this URL structure.

With the install callback implemented, you’re ready to try installing the app in your store control panel.

Another reminder: Make sure you have your dev server and remote tunnel running. If using VS Code or Git Codespace forwarding, you also must make sure your forwarded port has been set to “Public” visibility!

Log into your sandbox store using the same account that owns the app entry in the Developer Portal. Navigate to Apps -> Develop and choose your test app to start the install process.

Develop apps in store control panel

Install app

After installing, you should see the same home page with your gift certificates list, now rendered directly in your store control panel.

Gift certificates list in control panel

Example code

Implement the load callback

So far, only initial installation is handled by your app. Within the store control panel, if you navigate away from the app and then back (via its entry under “Apps” in the left navbar), the app will fail to load. You must implement the load callback to handle each time a user navigates to the app.

1

Implement payload verification

BigCommerce includes a signed payload in each load request. First, implement a utility function to verify the payload is valid.

Update the contents of src/lib/bc-auth/verify-signed-payload.ts as shown.

verify-signed-payload.ts
import { jwtVerify } from "jose";
import { z } from "zod";
const signedPayloadSchema = z.object({
sub: z.string(),
user: z.object({
id: z.number(),
email: z.string(),
locale: z.string().optional(),
}),
owner: z.object({
id: z.number(),
email: z.string(),
}),
url: z
.string()
.refine((url) => url.startsWith("/") && !url.startsWith("//"), {
message: "url must be a root-relative path (not protocol-relative).",
})
.optional(),
});
export type SignedPayload = z.infer<typeof signedPayloadSchema>;
export function parseStoreHash(storesSlashHash: string): string {
...
}
export async function verifySignedPayload(signedPayloadJwt: string): Promise<SignedPayload> {
const clientId = process.env.BIGCOMMERCE_CLIENT_ID;
const clientSecret = process.env.BIGCOMMERCE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("BIGCOMMERCE_CLIENT_ID and BIGCOMMERCE_CLIENT_SECRET must be set to verify a signed payload.");
}
const { payload } = await jwtVerify(signedPayloadJwt, new TextEncoder().encode(clientSecret), {
algorithms: ["HS256"],
issuer: "bc",
audience: clientId,
});
return signedPayloadSchema.parse(payload);
}

zod is used once again to validate that the payload exhibits the expected shape.

BigCommerce delivers the payload as a Json Web Token (JWT) signed with the registered app’s client secret. The verification logic uses jwtVerify with the secret to confirm the payload originated from BigCommerce and hasn’t been tampered with.

Note that the library jose is used for JWT handling. This library ensures the widest compatibility with different runtimes.

2

Implement the load action

The job of the load action is to perform the payload verification and ensure a record exists for the user in credentials storage.

Replace the contents of src/lib/bc-auth/load-store.ts.

load-store.ts
import { StoreNotInstalledError } from "@/lib/bc-auth/errors";
import { parseStoreHash, verifySignedPayload } from "@/lib/bc-auth/verify-signed-payload";
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";
export interface LoadStoreResult {
storeHash: string;
url: string;
}
export async function loadStore(signedPayloadJwt: string): Promise<LoadStoreResult> {
const payload = await verifySignedPayload(signedPayloadJwt);
const storeHash = parseStoreHash(payload.sub);
const credentialsStore = getCredentialsStore();
const token = await credentialsStore.getStoreToken(storeHash);
if (!token) {
throw new StoreNotInstalledError(storeHash);
}
await credentialsStore.setUser({ userId: payload.user.id, email: payload.user.email });
await credentialsStore.setStoreUser({ storeHash, userId: payload.user.id });
return { storeHash, url: payload.url ?? "/" };
}

A key detail to note about the load action: No store record is written to storage. Each store’s record is written, with its API token, only when the app is first installed on that store. The only potentially new information to record each time the load callback is called is the individual user who is accessing the app in this session.

3

Wire up the launch route handler

Implement the API route handler itself.

Replace the contents of src/app/api/app/load/route.ts.

app/load/route.ts
import { NextRequest, NextResponse } from "next/server";
import { isSignedPayloadVerificationError, StoreNotInstalledError } from "@/lib/bc-auth/errors";
import { getAppErrorUrl } from "@/lib/bc-auth/app-error-reason";
import { loadStore } from "@/lib/bc-auth/load-store";
import { getAbsoluteAppUrl } from "@/lib/routing/app-url";
import { logError } from "@/lib/errors/logger";
export async function GET(request: NextRequest): Promise<NextResponse> {
const signedPayloadJwt = request.nextUrl.searchParams.get("signed_payload_jwt");
if (!signedPayloadJwt) {
logError("GET /api/app/load", new Error("signed_payload_jwt is required."));
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("LOAD_FAILED")));
}
let storeHash: string;
let url: string;
try {
({ storeHash, url } = await loadStore(signedPayloadJwt));
} catch (error) {
if (error instanceof StoreNotInstalledError) {
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("NOT_INSTALLED")));
}
if (isSignedPayloadVerificationError(error)) {
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("INVALID_SESSION")));
}
logError("GET /api/app/load", error);
return NextResponse.redirect(getAbsoluteAppUrl(undefined, getAppErrorUrl("LOAD_FAILED")));
}
return NextResponse.redirect(getAbsoluteAppUrl(storeHash, url));
}

Return to your store control panel and try re-opening your app from the “Apps” menu.

Example code

Troubleshooting

  • Nothing reaches your app. The tunnel is down, its URL has changed, or its visibility is private. Load <APP_ORIGIN> directly in a browser to confirm.
  • A stale URL somewhere. After any tunnel URL change, APP_ORIGIN, all four Developer Portal callback URLs, and a dev server restart all have to agree. It’s also worth uninstalling and reinstalling the app, since the stored install is tied to the old origin.

See the full Running Locally as a Single-Click App guide for more failure modes.

Full step code

Full diff

Next: Session management