For years, merchants asked me the same question: "If we leave Magento, do we lose quick order, quotes and requisition lists?"
So I built the answer and open-sourced it. Forgeline is a B2B and B2C storefront for commercetools built with Next.js 15 (App Router) and the official TypeScript SDK.
- ๐ Live demo: https://forgeline-storefront-gamma.vercel.app
- ๐ป Code: https://github.com/sohelrana09/forgeline-storefront
It has company accounts with their own prices, quick order by SKU, purchase lists, request-a-quote, approval rules, a one-page checkout and a Magento-style minicart. It runs on Vercel's free tier.
This post isn't a feature tour. It's the six things that surprised me most, so you don't spend the same hours on them.
1. masterVariant.prices is empty, and that's correct
The B2B sample data uses standalone prices. Prices aren't embedded in products at all, so variant.prices is always []. My first product grid showed no prices, and I spent far too long looking for a bug.
The fix is price selection: you tell the query which store, currency, channel and country you're pricing for, and commercetools puts the one matching price on variant.price:
const priceSelection = (ctx: PricingContext) => ({
storeProjection: ctx.storeKey,
priceCurrency: ctx.currency,
priceChannel: ctx.channelId, // the store's distribution channel
...(ctx.country && { priceCountry: ctx.country }),
});
const { body } = await apiRoot
.productProjections()
.get({ queryArgs: { ...priceSelection(ctx), where: `slug(en-US="${slug}")` } })
.execute();
const price = body.results[0]?.masterVariant.price; // price.discounted if a discount applies
This is also how each company gets its own prices: a business unit belongs to a store, the store has a distribution channel, and the channel has its own standalone prices. One catch in the sample data: stores have no countries set, so country-specific prices only match if you pass priceCountry yourself.
2. Never let a customer token inherit your client's scopes
Customer login uses the password flow with a separate API client. If you don't pass a scope list, the token gets every scope the client has. If that client can do more than it should, every logged-in customer can too.
So the login always asks for an explicit, narrow list: only "my" resources, per store:
export async function customerScopes(): Promise<string[]> {
const stores = [...new Set([...(await getBuyingContexts()).map((c) => c.storeKey), GUEST_STORE_KEY])];
return [
`manage_my_profile:${projectKey}`,
...stores.map((s) => `manage_my_orders:${projectKey}:${s}`),
...stores.map((s) => `manage_my_shopping_lists:${projectKey}:${s}`),
`manage_my_quote_requests:${projectKey}`,
`manage_my_quotes:${projectKey}`,
];
}
The token lives only in an AES-256-GCM encrypted, httpOnly cookie, and admin endpoints answer 403 for it. Two more things I learned the hard way:
- Shopping lists only accept the per-store scope; the project-wide one is refused.
-
Don't use
/as-associatewith a customer token. It needs project-wideview_orders, which would let a customer read everyone's orders.
3. Approval rules live somewhere unexpected
commercetools has native approval rules and flows (Magento's "order approvals"). What I didn't expect: they only exist under the as-associate path:
/as-associate/{associateId}/in-business-unit/key={unit}/approval-flows
So the storefront calls them with the admin client, acting as the signed-in associate. That's safe only with two rules:
- The associate id always comes from the session, never from form input.
- Every write is preceded by a live permission check against the unit's current roles (not a cached copy).
Another surprise: a rejected order stays
Open. The approval flow says "Rejected", but the order itself isn't cancelled, so the app cancels it.
4. Fast catalog pages for guests, personal prices for buyers, same URLs
Guests and logged-in buyers see the same URLs, but different prices. I wanted cached, instant pages for guests without ever caching a buyer's company prices.
The trick is a tiny middleware that rewrites guest requests to an ISR copy of the page:
export function middleware(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl;
if (request.method !== "GET" && request.method !== "HEAD") return NextResponse.next();
if (!CATALOG.test(pathname)) return NextResponse.next(); // /, /c/x, /p/x, /pages/x
if (request.cookies.has(SESSION_COOKIE)) return NextResponse.next(); // signed in: per-visitor page
if ([...searchParams.keys()].some((k) => !IGNORED_PARAM.test(k))) return NextResponse.next();
const url = request.nextUrl.clone();
url.pathname = pathname === "/" ? "/g" : `/g${pathname}`; // cached guest copy (revalidate = 300)
return NextResponse.rewrite(url);
}
The cached pages must never read cookies (their layout uses dynamic = "error" so the build fails if anything tries). The header's minicart there is a client island that fetches the visitor's own cart from a private, no-store API route.
Result on Vercel: catalog pages paint in about 1 second, and the homepage HTML is about 19 KB.
5. The biggest speed win was a config line
From my desk, every commercetools call took about 400 ms. Signed-in company pages made around seven calls in a row, so roughly 3 seconds. I first assumed my code was slow.
Two fixes:
Put the server next to the API. commercetools ran in AWS us-east-2 (Ohio), so I pinned Vercel functions to Cleveland:
{ "regions": ["cle1"] }
Each API call went from hundreds of milliseconds to a few.
Stop asking the same question twice per page. Several components and permission checks read the same business unit and role list. React's cache() makes that one read per render:
export const businessUnitNow = cache(async (key: string) =>
(await apiRoot.businessUnits().withKey({ key }).get().execute()).body,
);
Then independent calls run together with Promise.all. Signed-in company pages got 2 to 2.5 times faster.
One caveat that matters: React's cache is off inside Server Actions, which is exactly what you want. Writes and their permission checks always read live data.
6. Spam protection without CAPTCHAs
Sign-up, login, password reset and guest checkout are public forms, so bots will find them. I wanted no CAPTCHA puzzles for real buyers. Three layers, all free:
Vercel BotID: invisible bot detection, checked with checkBotId() inside each Server Action.
A honeypot plus a time check: a hidden field bots fill in, and a minimum time the form must be open (measured in the browser, so a wrong clock can't block anyone).
Shared rate limits on Upstash Redis. In-memory limits don't work on serverless, because every instance has its own memory, so "10 attempts" quietly becomes "10 per instance".
A detail I like: when a bot hits "forgot password", it gets the normal "check your email" reply, but no email is sent. It can't tell it was caught, and the answer never reveals whether an account exists.
What's next
Forgeline is a reference build, not a finished product. Still on the list: company-shared purchase lists, an ERP connector, and Next.js 16 with Cache Components.
If you're moving a Magento B2B shop to composable commerce, or you're an agency that needs a real B2B example, I'd love your feedback:
- โญ Code: https://github.com/sohelrana09/forgeline-storefront
- ๐ Demo: https://forgeline-storefront-gamma.vercel.app Which B2B feature would you build next? Tell me in the comments.
Top comments (0)