Skip to main content

Embed in admin

This is Part 2 of the "Build a Shoplazza App with Node.js + Express.js" series. In Part 1 you built a standalone app that opens in its own browser tab and identifies the store from the shop URL parameter. Here you'll build an embedded app that runs inside the Shoplazza admin (in an iframe) and authenticates with App Bridge + Session Token.

The app you build loads inside the store admin, fetches the current store's information, and displays it. You'll also add an App Bridge contextual save bar so the app feels native to the admin.

What you'll learn

In this tutorial, you'll learn how to:

  • Tell an embedded app apart from a standalone app
  • Get a Session Token inside the iframe with the App Bridge SDK
  • Verify the Session Token (JWT) on your backend to authenticate requests
  • Bundle the frontend with Vite and serve it from Express
  • Call the Shoplazza OpenAPI to read store information
  • Use App Bridge native components such as the contextual save bar

Embedded apps vs standalone apps

The OAuth flow and HMAC validation are identical to a standalone app. The differences are only in how the app runs and how the frontend authenticates with the backend.

AspectStandalone (Part 1)Embedded (this tutorial)
Where it runsIts own browser tab, own domainInside the Shoplazza admin iframe
Frontend ↔ backend authshop URL parameter + server-held access_tokenSession Token (JWT) in Authorization: Bearer <token>
FrontendCan be server-rendered HTMLBundled frontend (Vite) with the App Bridge SDK
shop sourceTrusts ?shop= (can be spoofed)Decoded from the verified JWT (cannot be spoofed)
Extra dependenciesjsonwebtoken, shoplazza-app-bridge, vite

For the concepts behind this, see App types and the App Bridge overview.

Prerequisites

  • You have completed Part 1, or you are familiar with the OAuth flow and HMAC validation it covers
  • An app created in Shoplazza Partners with embedding enabled (see Step 2)
  • A test store created in Partners
  • Node.js version ≥ 18

Step 1: Set up the project foundation

The project setup, ngrok tunnel, .env file, and OAuth utilities are the same as Part 1. Reuse them here and only add what's new for an embedded app.

1.1 Create the project and reuse Part 1 utilities

mkdir embedded-app-demo
cd embedded-app-demo
npm init -y

Copy these two files from your Part 1 project — they are unchanged:

  • utils/index.js — environment check, HMAC validation, isValidShop, and the state store (see Part 1, Step 4.1)
  • utils/sqlite.jssaveToken / getShopToken keyed by store domain (see Part 1, Step 4.4)

1.2 Install dependencies

npm install express axios dotenv sqlite3 jsonwebtoken shoplazza-app-bridge
npm install -D vite nodemon

The first four (express, axios, dotenv, sqlite3) are the same as Part 1. The new ones are embedded-specific:

  • jsonwebtoken: verifies the Session Token (JWT) that the frontend sends — the core of embedded authentication
  • shoplazza-app-bridge: the App Bridge frontend SDK, used inside the iframe to talk to the admin, get the Session Token, and call native components
  • vite: bundles the frontend
  • nodemon: restarts the server automatically during development

crypto is a built-in Node.js module (used for HMAC validation). Do not install it from npm.

1.3 Configure the build scripts

The frontend now needs a build step, so update the scripts in package.json:

{
"scripts": {
"build": "vite build",
"start": "npm run build && nodemon index.js",
"dev": "nodemon index.js"
}
}
  • build: bundles src/main.js into dist/ with Vite
  • start: builds the frontend, then starts the server
  • dev: starts the server only (use it when the frontend is already built)

Step 2: Enable embedding in Partners

In your Shoplazza Partners app settings:

  1. Turn on App embed (embed into the store admin) so the app loads as an iframe inside the admin.
  2. Set the two callback URLs (use your ngrok HTTPS address from Part 1, Step 2):
    • App URL: https://xxx.ngrok-free.dev/auth
    • Redirect URL: https://xxx.ngrok-free.dev/auth/callback

The .env file is the same as Part 1 (CLIENT_ID, CLIENT_SECRET, BASE_URL, SCOPES). For this tutorial use SCOPES=read_shop.

Step 3: Implement the OAuth flow

The OAuth routes are the same as Part 1: /auth validates HMAC and redirects to the authorization page, and /auth/callback exchanges the code for an access_token and stores it in SQLite.

There is one embedded-specific change: after the callback, redirect to /, which returns the frontend bundle instead of a server-rendered result. The store information is fetched later by the frontend through a Session Token.

Create index.js in the project root:

require("dotenv").config();
const express = require("express");
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const axios = require("axios");
const jwt = require("jsonwebtoken");

const {
checkEnvMiddleware,
hmacValidator,
isValidShop,
saveState,
consumeState,
CLIENT_ID,
CLIENT_SECRET,
SCOPES,
REDIRECT_URI,
} = require("./utils/index");
const { saveToken, getShopToken } = require("./utils/sqlite");

const app = express();

// Serve the Vite-bundled frontend
app.use("/static", express.static(path.join(__dirname, "dist")));

// Authorization entry — same as Part 1
app.get("/auth", checkEnvMiddleware, hmacValidator, (req, res) => {
const { shop } = req.query;
if (!isValidShop(shop)) {
return res.status(400).send("Invalid shop domain");
}
const state = crypto.randomBytes(16).toString("hex");
saveState(state, shop);

const scopesEncoded = encodeURIComponent(SCOPES);
const redirectUriEncoded = encodeURIComponent(REDIRECT_URI);

res.redirect(
`https://${shop}/admin/oauth/authorize?client_id=${CLIENT_ID}&scope=${scopesEncoded}&redirect_uri=${redirectUriEncoded}&response_type=code&state=${state}`
);
});

// Authorization callback — same as Part 1, but redirects to the frontend
app.get("/auth/callback", checkEnvMiddleware, hmacValidator, async (req, res) => {
const { shop, code, state } = req.query;
if (!isValidShop(shop) || !consumeState(state, shop)) {
return res.status(400).send("Invalid shop or state");
}

const { data } = await axios.post(`https://${shop}/admin/oauth/token`, {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
});

await saveToken(shop, data.access_token);

// Embedded difference: redirect to the frontend entry, not a rendered result
res.redirect("/");
});

For the full explanation of HMAC validation and the token exchange, see Part 1, Step 4 and the App Authorization Reference.

Step 4: Verify the Session Token

This is what sets an embedded app apart from a standalone app.

Because the app runs in an iframe, every request the frontend makes carries a Session Token (JWT). Your backend verifies the JWT's signature with CLIENT_SECRET, which proves the request really comes from the currently logged-in store and user. The store domain is then read from the verified token (dest), not from a shop parameter that anyone could forge.

Add the middleware to index.js:

function verifySessionToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "Missing session token" });
}

const token = authHeader.replace("Bearer ", "");

try {
// jwt.verify checks the signature and the `exp` expiry automatically
const decoded = jwt.verify(token, CLIENT_SECRET, { algorithms: ["HS256"] });

// Attach trusted values from the verified token to the request
req.session = {
shop: decoded.dest, // shop domain (trusted)
userId: decoded.sub, // user ID
locale: decoded.locale, // language
account: decoded.account, // user account
};

next();
} catch (err) {
return res.status(401).json({ error: "Invalid session token" });
}
}

For the JWT structure, every claim's meaning, and more on backend verification, see Session Token.

Step 5: Serve the embedded page

The entry route / doesn't render data. It returns the frontend HTML and injects CLIENT_ID into a <meta> tag so the App Bridge SDK can read it.

When the app is installed from Partners, the URL may not include a shop parameter. The frontend gets the store from the Session Token instead, so this route doesn't need one.

Add to index.js:

app.get("/", (req, res) => {
let html = fs.readFileSync(path.resolve(__dirname, "index.html"), "utf8");

// Inject CLIENT_ID for the frontend App Bridge SDK
html = html.replace(
"<head>",
`<head>\n <meta name="client-id" content="${CLIENT_ID}">`
);

res.status(200).send(html);
});

Step 6: Implement the protected API endpoint

The store information endpoint is protected by verifySessionToken. The shop comes from the verified JWT, and the access_token is read from SQLite to call the OpenAPI.

Add to index.js:

app.get("/api/shop-info", verifySessionToken, async (req, res) => {
const shop = req.session.shop; // from the verified JWT — trusted

const token = await getShopToken(shop);
if (!token) {
return res.status(401).json({ error: "Shop not authorized", needAuth: true });
}

try {
const { data } = await axios.get(`https://${shop}/openapi/2025-06/shop`, {
headers: { "Access-Token": token },
});
res.json(data);
} catch (err) {
const status = err.response?.status || 500;
res.status(status).json({ error: err.response?.data || err.message });
}
});

const PORT = 3001;
app.listen(PORT, () =>
console.log(`Embedded App is listening on port ${PORT}`)
);

The backend is now complete. The full flow is: install from Partners → /auth (HMAC) → authorization page → /auth/callback exchanges the token → redirect to / returns the frontend → the frontend calls /api/shop-info with the Session Token.

Step 7: Build the frontend with Vite and App Bridge

7.1 Configure Vite

Create vite.config.js to bundle src/main.js into dist/main.js:

import { defineConfig } from "vite";

export default defineConfig({
build: {
outDir: "dist",
rollupOptions: {
input: { main: "src/main.js" },
output: { entryFileNames: "[name].js", format: "es" },
},
},
});

7.2 Create the HTML page

Create index.html in the project root. It loads the bundled frontend from /static, which Express serves from dist/:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Shoplazza Embedded App</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; padding: 20px; background: #f5f5f5; }
#loading { text-align: center; padding: 40px; color: #666; }
#content { display: none; background: #fff; padding: 20px; border-radius: 8px; }
pre { background: #f5f5f5; padding: 15px; border-radius: 5px; overflow-x: auto; }
</style>
</head>
<body>
<div id="loading">Loading...</div>
<div id="content">
<h1>Authorized ✅</h1>
<div>Shop info:</div>
<div id="shop-info"></div>
<div class="save-box">
<input class="save-input" type="text" value="demo">
</div>
</div>

<script type="module" src="/static/main.js"></script>
</body>
</html>

Don't add <meta name="client-id"> here. The backend injects it when serving the page (see Step 5).

7.3 Write the frontend entry

Create src/main.js. It does the three things every embedded frontend needs: initialize App Bridge, get a Session Token, and call the backend with a Bearer header.

import { app, getSessionToken } from "shoplazza-app-bridge";

// Read CLIENT_ID from the meta tag injected by the backend
const meta = document.querySelector('meta[name="client-id"]');
const CLIENT_ID = meta ? meta.content : "";

// Initialize App Bridge
app.init();

async function fetchShopInfo() {
// A Session Token expires in ~1 minute — get a fresh one for every request
const sessionToken = await getSessionToken(CLIENT_ID);

const response = await fetch("/api/shop-info", {
headers: { Authorization: "Bearer " + sessionToken },
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Request failed");
}

const data = await response.json();
document.getElementById("loading").style.display = "none";
document.getElementById("content").style.display = "block";
document.getElementById("shop-info").innerHTML =
"<pre>" + JSON.stringify(data, null, 2) + "</pre>";
}

fetchShopInfo().catch((err) => {
document.getElementById("loading").textContent = "Failed: " + err.message;
});

For App Bridge installation and initialization details, see the App Bridge overview.

Step 8: Add a native component (optional)

An embedded app can call the admin's native UI components. The example below adds the contextual save bar: when the input value changes, a "Save / Discard" bar appears at the top of the admin.

Append to src/main.js:

import { contextualSaveBar } from "shoplazza-app-bridge";

const saveInput = document.querySelector(".save-input");
const initialValue = saveInput.value;

contextualSaveBar.hide();

saveInput.addEventListener("input", (e) => {
if (e.target.value === initialValue) {
contextualSaveBar.hide();
} else {
contextualSaveBar.show();
}
});

contextualSaveBar.setSaveAction({
type: "primary",
text: "Save",
onClick: () => {
console.log("Saved:", saveInput.value);
contextualSaveBar.hide();
},
});

contextualSaveBar.setDiscardAction({
type: "default",
text: "Discard",
discardConfirmationModal: true,
onClick: () => {
saveInput.value = initialValue;
contextualSaveBar.hide();
},
});

App Bridge also provides BackLink and Redirect. See App Bridge actions for the full list.

Step 9: Build and test

9.1 Start the app

npm run start

This bundles src/main.js into dist/ with Vite, then starts Express on port 3001. In another terminal, start ngrok:

ngrok http 3001

9.2 Install to a test store

  1. Log in to Shoplazza Partners, open your app → App Testing → select a store → Install.
  2. You're redirected to the Shoplazza authorization page. Click Install.
  3. After authorization, the app opens as an iframe inside the store admin.

9.3 Verify

  • The page switches from "Loading..." to "Authorized ✅" and shows the current store's information (fetched from the OpenAPI after the Session Token is verified).
  • Change the input value and the native "Save / Discard" bar appears at the top of the admin.

Troubleshooting

  • Missing session token / Invalid session token: the frontend didn't get or didn't send the Session Token. Check that CLIENT_ID is injected correctly and that getSessionToken doesn't throw.
  • Session token expired: a token expires in about 1 minute. Get a fresh one before every request — never cache it.
  • HMAC validation failed: CLIENT_SECRET is wrong, or the callback URL doesn't match the one in Partners.
  • The iframe won't load: confirm App embed is enabled in Partners and that App URL / Redirect URL match the /auth and /auth/callback routes.
  • Static asset 404: confirm vite build produced dist/ and that the /static mapping is correct.

Next steps

Congratulations! You've built an embedded Shoplazza App that authenticates with a Session Token and renders inside the store admin. You can now: