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.
| Aspect | Standalone (Part 1) | Embedded (this tutorial) |
|---|---|---|
| Where it runs | Its own browser tab, own domain | Inside the Shoplazza admin iframe |
| Frontend ↔ backend auth | shop URL parameter + server-held access_token | Session Token (JWT) in Authorization: Bearer <token> |
| Frontend | Can be server-rendered HTML | Bundled frontend (Vite) with the App Bridge SDK |
shop source | Trusts ?shop= (can be spoofed) | Decoded from the verified JWT (cannot be spoofed) |
| Extra dependencies | — | jsonwebtoken, 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 thestatestore (see Part 1, Step 4.1)utils/sqlite.js—saveToken/getShopTokenkeyed 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 authenticationshoplazza-app-bridge: the App Bridge frontend SDK, used inside the iframe to talk to the admin, get the Session Token, and call native componentsvite: bundles the frontendnodemon: restarts the server automatically during development
cryptois 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: bundlessrc/main.jsintodist/with Vitestart: builds the frontend, then starts the serverdev: starts the server only (use it when the frontend is already built)
Step 2: Enable embedding in Partners
In your Shoplazza Partners app settings:
- Turn on App embed (embed into the store admin) so the app loads as an iframe inside the admin.
- Set the two callback URLs (use your ngrok HTTPS address from Part 1, Step 2):
App URL:https://xxx.ngrok-free.dev/authRedirect 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
shopparameter. 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
- Log in to Shoplazza Partners, open your app → App Testing → select a store → Install.
- You're redirected to the Shoplazza authorization page. Click Install.
- 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 thatCLIENT_IDis injected correctly and thatgetSessionTokendoesn'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_SECRETis 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 URLmatch the/authand/auth/callbackroutes. - Static asset 404: confirm
vite buildproduceddist/and that the/staticmapping 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:
- Use a frontend framework: replace
src/main.jswith a React or Vue entry and build a richer admin UI with App Bridge - Explore more native components: see App Bridge actions
- Publish the app: submit it to the App Store. See Shoplazza App Submission
- Deep dive into authorization: see the App Authorization Reference