Standard B2C e-commerce stacks fall apart when you need login-gated catalogs, per-customer pricing tiers, and minimum order quantities. Most teams end up duplicating product entries per tier or bolting discount logic onto a frontend that was never designed for it. Both approaches break as the catalog scales.
This tutorial takes a different route. You'll build a Strapi 5 backend that stores one product record with multiple tier prices attached, then uses a custom policy and controller override to return the right price for each authenticated buyer. On the frontend, a Next.js 16 App Router application handles JWT-based auth, server-side catalog rendering, and a quote request flow that replaces the typical cart checkout.
The build covers content modeling, role-to-tier mapping, authenticated server components, and the authorization plumbing connecting all of it. Plan for roughly three hours.
In brief:
- Model a B2B wholesale catalog in Strapi 5 with a repeatable
TierPricecomponent on each product. - Gate product access and personalize pricing using a custom policy plus controller override.
- Connect a Next.js 16 App Router frontend with JWT auth stored in
httpOnlycookies. - Ship a quote request flow instead of a traditional cart checkout.
What You're Building
The architecture splits into two parts with different responsibilities. Strapi 5 owns access control, tier-to-price logic, and catalog data. Next.js 16 owns rendering, the buyer session, and the quote flow.
Architecture Overview
The system has two halves: a Strapi 5 backend managing catalog data and access control, and a Next.js 16 frontend rendering the gated catalog.
- Strapi 5 backend with
Product,Category, andCustomerTiercollection types. - A
TierPricerepeatable component for per-tier pricing attached to each product. - Users & Permissions roles mapped to customer tiers (Bronze, Silver, Gold).
- A custom policy plus controller override that filters product responses by the requesting user's tier.
- Next.js 16 frontend (App Router) with JWT-based auth and server components fetching tier-aware data.
Prerequisites
This tutorial assumes you have a working local development environment and some experience with both frameworks. Before you start, confirm the following are in place:
- Node.js v20, v22, or v24 (LTS).
- npm or pnpm.
- Familiarity with the Next.js App Router and async
paramsin Next.js 16. - Working knowledge of Strapi's Content-Type Builder.
- Roughly three hours.
Set Up the Strapi 5 Backend
The backend needs three things before you model any content: a running Strapi instance, Cross-Origin Resource Sharing (CORS) configured for the frontend origin, and an API token for unauthenticated reads.
Bootstrap the Project
The create-strapi CLI generates a fresh Strapi 5 project with all the default configuration files and directory structure in place. Run the following command to scaffold the backend:
npx create-strapi@latest b2b-catalog-backendThe command-line interface (CLI) walks you through several prompts. You can skip the Strapi Cloud login, choose TypeScript (the default), accept SQLite for development, and decline the example template.
cd b2b-catalog-backend && npm run developCreate your first administrator account from the welcome screen. The dev server runs on http://localhost:1337 by default.
Configure CORS for the Next.js Frontend
In config/middlewares.ts, replace the plain 'strapi::cors' string with a configured object. This tells Strapi to accept requests from the Next.js dev server at port 3000:
// config/middlewares.ts
export default [
'strapi::logger',
'strapi::errors',
'strapi::security',
{
name: 'strapi::cors',
config: {
origin: ['http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
headers: ['Content-Type', 'Authorization', 'Origin', 'Accept'],
keepHeaderOnError: true,
},
},
'strapi::poweredBy',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];The origin array above is scoped to localhost:3000 for development. In production, replace that value with your real domain so the backend only accepts requests from your deployed frontend. A wildcard * origin would let any site make authenticated requests to your API, which defeats the access control you're building in later sections.
Generate an API Token for Server-Side Reads
Navigate to Settings → API Tokens → Create new API token in the Admin Panel. Set the token type to Custom and scope it to find and findOne on the Category Collection Type only. Store the value as STRAPI_API_TOKEN in .env.local on the Next.js side. In production, rotate API tokens periodically and revoke any that are no longer in use.
Two different tokens come into play throughout this build, and readers frequently conflate them. This API token handles non-personalized public reads like categories and marketing content. Authenticated buyer requests carry a separate JWT obtained from /api/auth/local.
The API token never touches product data. Because the token is scoped narrowly to Category reads, even if it leaks, product pricing stays protected behind the JWT-authenticated path.
Model the B2B Wholesale Catalog
Four pieces make up the data layer: a CustomerTier collection defining pricing levels, a Category collection for organization, a Product collection with an embedded TierPrice component, and the existing User type extended with a tier relation. One thing to keep in mind throughout is the flat response format with documentId at the top level. There's no .data.attributes wrapper. All data-shape examples below reflect that.
Storing tier prices in a structured repeatable component gives content editors a clean UI in the Content Manager inside the headless CMS, with labeled fields, dropdown relations, and validation rules. A JSON field would require editors to hand-write valid arrays, which invites typos and blocks non-technical team members from updating prices.
CustomerTier Collection Type
CustomerTier represents the pricing levels your buyers fall into, such as Bronze, Silver, and Gold. Open the Content-Type Builder and create a new Collection Type called CustomerTier with these fields:
name(Text, unique, required)discountPercent(Number/integer, 0–100)description(Text)
Seed three records via the admin: Bronze, Silver, and Gold. Storing tiers as a Collection Type lets the business add or rename tiers without a redeploy.
Category Collection Type
Categories group products so buyers can browse the catalog by product line or department. Create a Category Collection Type with the following fields:
name(Text, required)slug(UID, targetingname)description(Text)
Skip nested categories for version one. That's a natural extension point covered at the end.
Product Collection Type
Product is the central Content-Type in the catalog, holding everything from the SKU and base price to the tier-specific pricing component you'll wire up next. Create a Product Collection Type with these fields:
name(Text, required)sku(Text, unique, required), the stock-keeping unit (SKU) identifier for each productslug(UID, targetingname)description(Rich Text, Blocks editor)basePrice(Decimal)minOrderQty(Number/integer, default 1)images(Media, multiple)category(Relation, many-to-one with Category)
Next, create a component in the pricing category called TierPrice with two fields: tier (Relation to CustomerTier) and price (Decimal). Add this component to Product as a repeatable field named tierPrices.
Nested components must be explicitly populated when queried through the Document Service API. The incorrect pattern silently omits the nested relation:
// Correct: nested populate object inside the component
populate: {
tierPrices: {
populate: { tier: true },
},
}Strapi 5 does not auto-populate relations nested inside components. Without the explicit nested populate object, the tier relation inside each tierPrices entry is not returned in the API response, and you'll have no way to match a price to a buyer's tier. It helps to use the object-form populate syntax whenever a component contains a relation field.
Wire Buyers to Tiers
Extend the User Content-Type (Users & Permissions → User) by adding a one-to-one relation to CustomerTier. This relation is what the is-buyer policy reads later to determine which price the buyer sees. Without it, the controller override has no way to match a requesting user to a tier-specific price entry.
In Settings → Users & Permissions Plugin → Roles, create three roles: bronze-buyer, silver-buyer, gold-buyer. Grant the Authenticated role find and findOne permissions for the Category Collection Type. Leave Public permissions disabled for Product, because the catalog is gated.
Create two or three test users through the admin. Assign each a role and set their customerTier value. You'll use these accounts to verify tier-specific responses later.
A common pitfall is forgetting to set the customerTier relation on a test user. In that case, the policy rejects the request with "No customer tier assigned." Verify the relation is set in the Content Manager before testing authenticated requests. Open the user entry and confirm the customerTier field shows a tier name, not an empty value.
Tier-Aware Authorization for the B2B Catalog
This is the core of the B2B catalog: a policy that gates access, and a controller override that personalizes the response payload.
Why the Default Permissions Aren't Enough
Default permissions are binary: a user either sees all products or none. B2B needs a third behavior, where the buyer sees products but with prices scoped to their tier. That requires custom logic on the response, not just the access check.
Without that custom logic, you'd have two unworkable alternatives. First, you could create separate Product collections per tier (Bronze Products, Silver Products, Gold Products), but that means tripling every catalog entry and keeping them in sync manually.
Second, you could expose a single price and handle discounts externally, but then the API leaks base pricing to every buyer regardless of their negotiated rate. The custom policy and controller override keep a single product record with multiple price points attached. The controller selects the correct price at query time based on the requesting buyer's tier, so the headless CMS catalog scales without duplicating content.
Create a Policy That Verifies the Buyer's Tier
The is-buyer policy checks whether the requesting user has a valid customerTier relation before allowing access to product data. Strapi's built-in generator creates the boilerplate file; run the interactive CLI and select policy when prompted:
npm run strapi generateName it is-buyer. The file lands at src/policies/is-buyer.ts.
Only the role relation is populated on ctx.state.user by default. Custom relations like customerTier need an explicit fetch:
// src/policies/is-buyer.ts
export default async (policyContext, config, { strapi }) => {
const user = policyContext.state.user;
if (!user) {
policyContext.unauthorized('You must be logged in');
return false;
}
// Populate the customerTier relation (not included by default)
const fullUser = await strapi
.documents('plugin::users-permissions.user')
.findOne({
documentId: user.documentId,
populate: ['customerTier'],
});
if (!fullUser?.customerTier) {
policyContext.unauthorized('No customer tier assigned');
return false;
}
// Attach for downstream use in the controller
policyContext.state.user.customerTier = fullUser.customerTier;
return true;
};Wire the policy into product routes via route-level config:
// src/api/product/routes/product.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreRouter('api::product.product', {
config: {
find: {
policies: ['global::is-buyer'],
},
findOne: {
policies: ['global::is-buyer'],
},
},
});Override the Product Controller for Tier-Specific Pricing
The controller reads the buyer's tier, set by the policy, finds the matching tierPrices entry, and returns a flat price field per product while stripping the raw tierPrices array. This uses the Document Service API (strapi.documents()), not the deprecated Entity Service:
// src/api/product/controllers/product.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController(
'api::product.product',
({ strapi }) => ({
async find(ctx) {
const tierDocId = ctx.state.user.customerTier.documentId;
const results = await strapi
.documents('api::product.product')
.findMany({
status: 'published',
populate: {
tierPrices: { populate: { tier: true } },
category: true,
images: true,
},
});
const mapped = results.map((product) => {
const match = product.tierPrices?.find(
(tp) => tp.tier?.documentId === tierDocId
);
const { tierPrices, ...rest } = product;
return { ...rest, price: match?.price ?? product.basePrice };
});
return this.transformResponse(mapped, {});
},
async findOne(ctx) {
const { id: documentId } = ctx.params;
const tierDocId = ctx.state.user.customerTier.documentId;
const product = await strapi
.documents('api::product.product')
.findOne({
documentId,
status: 'published',
populate: {
tierPrices: { populate: { tier: true } },
category: true,
images: true,
},
});
if (!product) return ctx.notFound('Product not found');
const match = product.tierPrices?.find(
(tp) => tp.tier?.documentId === tierDocId
);
const { tierPrices, ...rest } = product;
return this.transformResponse(
{ ...rest, price: match?.price ?? product.basePrice },
{}
);
},
})
);This controller bypasses the built-in sanitizeQuery, validateQuery, and sanitizeOutput helpers because it constructs its own query rather than passing through user-supplied parameters.
In a production deployment where clients can pass query parameters (filters, pagination, field selection), add those sanitization steps back in. The controllers docs show the full pattern with this.sanitizeQuery(ctx) and this.sanitizeOutput(results, ctx).
To verify the setup, log in as one of your Bronze test users by posting credentials to /api/auth/local and copying the returned JWT. Then hit GET /api/products with the JWT in the Authorization: Bearer header:
curl -H "Authorization: Bearer <your-jwt>" http://localhost:1337/api/productsThe response should show price as a top-level field on each product object, with no tierPrices array present. The values should match the Bronze-tier entries you created on each product.
Log out and repeat with a Gold buyer account. The price field on each product should reflect Gold-tier pricing. If both accounts return identical prices, the tier matching logic is only useful when the tier prices actually differ, so make sure you seeded distinct values per tier.
If you get a 403, confirm that the is-buyer policy is wired in the route config file and that the user's role has Product find and findOne permissions enabled in the Users & Permissions settings.
If price comes back equal to basePrice for every tier, verify that tierPrices entries exist on the product in the Content Manager and that each entry's tier relation points to the correct CustomerTier record.
Connect a Next.js 16 Frontend to the B2B Catalog
The frontend has three responsibilities: log the buyer in, store the JWT safely, and call Strapi from server components on every catalog request.
Bootstrap Next.js 16
Create the frontend project with the App Router and Tailwind CSS enabled:
npx create-next-app@latest b2b-catalog-frontend --typescript --app --tailwindInstall jose for any JWT decoding you need inside a proxy file or Route Handler. Strapi issues the token, so you can use its built-in authentication to obtain JWTs without a separate auth service, though client-side token handling still needs to be implemented:
cd b2b-catalog-frontend && npm install jose server-onlyAdd environment variables to .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=<your-category-read-token>Login Server Action and JWT Handling
A Server Action can POST credentials to Strapi's auth endpoint and store the returned JWT as an httpOnly cookie. The JWT lives in a cookie rather than localStorage for cross-site scripting (XSS) resistance.
// app/(auth)/login/actions.ts
'use server'
import 'server-only'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function login(formData: FormData) {
const identifier = formData.get('identifier') as string;
const password = formData.get('password') as string;
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/auth/local`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
}
);
if (!res.ok) {
throw new Error('Invalid credentials');
}
const { jwt } = await res.json();
const cookieStore = await cookies();
cookieStore.set({
name: 'strapi_jwt',
value: jwt,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
redirect('/catalog');
}Note that cookies() is async in Next.js 16. Version 15 introduced this change with a temporary synchronous compatibility shim, and Next.js 16 fully removes synchronous access. Every call must be awaited.
The login form itself is a Client Component that passes the server action to a standard <form>:
// app/(auth)/login/page.tsx
'use client'
import { login } from './actions'
export default function LoginPage() {
return (
<form action={login} className="max-w-sm mx-auto mt-20 space-y-4">
<h1 className="text-2xl font-bold">Buyer Login</h1>
<input
name="identifier"
type="email"
placeholder="Email"
required
className="w-full border rounded px-3 py-2"
/>
<input
name="password"
type="password"
placeholder="Password"
required
className="w-full border rounded px-3 py-2"
/>
<button
type="submit"
className="w-full bg-blue-600 text-white rounded py-2"
>
Sign in
</button>
</form>
);
}Authenticated Fetch Helper
The import 'server-only' directive causes a hard build-time failure if this module is ever imported into a Client Component, which prevents the JWT cookie from leaking into the client bundle:
// src/lib/strapi.ts
import 'server-only';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function fetchFromStrapi(path: string, options?: RequestInit) {
const cookieStore = await cookies();
const token = cookieStore.get('strapi_jwt')?.value;
if (!token) redirect('/login');
const res = await fetch(
`${process.env.NEXT_PUBLIC_STRAPI_URL}${path}`,
{
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options?.headers,
},
cache: 'no-store',
}
);
if (res.status === 401) {
// Cookie will expire naturally; redirect forces re-authentication
redirect('/login');
}
return res.json();
}Protect Catalog Routes with a Proxy
The fetchFromStrapi helper redirects on missing tokens, but a Next.js proxy file at proxy.ts in the project root can short-circuit unauthenticated requests before they reach the page component. Next.js 16 renamed the middleware convention to proxy and runs it on the Node.js runtime, so the page's server component never executes for unauthenticated visitors.
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
export function proxy(request: NextRequest) {
const token = request.cookies.get('strapi_jwt')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/catalog/:path*', '/quote/:path*'],
};The matcher array restricts the proxy to catalog and quote routes, so public pages like the login form load without interference. The jose package installed earlier can optionally decode the token and check the exp claim inside the proxy function, letting you redirect on expired tokens before any server component data fetching runs.
If you are upgrading from Next.js 15, the official codemod can rename
middlewaretoproxyfor you. Theedgeruntime is not supported inproxy; keep the legacymiddlewarefilename if you specifically need the edge runtime.
Render the Gated Catalog
The catalog page is a server component that calls the authenticated fetch helper and maps the tier-adjusted response to product cards:
// app/catalog/page.tsx
import { fetchFromStrapi } from '@/lib/strapi';
import Image from 'next/image';
import Link from 'next/link';
export default async function CatalogPage() {
const { data: products } = await fetchFromStrapi('/api/products');
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 p-8">
{products.map((product: any) => (
<Link
key={product.documentId}
href={`/catalog/${product.documentId}`}
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
>
{product.images?.[0] && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${product.images[0].url}`}
alt={product.name}
width={400}
height={300}
className="rounded"
/>
)}
<h2 className="text-xl font-semibold mt-2">{product.name}</h2>
<p className="text-sm text-gray-500">SKU: {product.sku}</p>
<p className="text-lg font-bold mt-1">${product.price.toFixed(2)}</p>
<p className="text-sm">Min. order: {product.minOrderQty}</p>
</Link>
))}
</div>
);
}For the product detail page at app/catalog/[documentId]/page.tsx, remember that params is a Promise in Next.js 16 async server components. You must await it:
// app/catalog/[documentId]/page.tsx
import { fetchFromStrapi } from '@/lib/strapi';
import Image from 'next/image';
type Params = Promise<{ documentId: string }>;
export default async function ProductPage(props: { params: Params }) {
const { documentId } = await props.params;
const { data: product } = await fetchFromStrapi(`/api/products/${documentId}`);
return (
<div className="max-w-3xl mx-auto p-8">
{product.images?.[0] && (
<Image
src={`${process.env.NEXT_PUBLIC_STRAPI_URL}${product.images[0].url}`}
alt={product.name}
width={600}
height={400}
className="rounded mb-6"
/>
)}
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-sm text-gray-500 mt-1">SKU: {product.sku}</p>
<p className="text-2xl font-bold mt-4">${product.price.toFixed(2)}</p>
<p className="text-sm mt-1">Minimum order: {product.minOrderQty} units</p>
<div className="prose mt-6">{product.description}</div>
<button className="mt-6 bg-blue-600 text-white px-6 py-3 rounded">
Add to quote
</button>
</div>
);
}Add a Quote Request Flow
B2B typically closes through a quote or purchase order, not a card checkout. If you're keeping the flow lightweight, it's reasonable to skip Stripe and stay on the canonical path.
QuoteRequest Collection Type
QuoteRequest captures what a buyer wants to order and tracks the sales team's response. Each record ties a list of products and quantities to the authenticated buyer who submitted it. Create a QuoteRequest Collection Type in the Content-Type Builder with the following fields:
items(JSON), storing an array of objects with the schema[{ productDocumentId, quantity }]buyer(Relation to User, many-to-one)status(Enumeration:pending,quoted,won,lost, defaultpending)notes(Text)
Grant the Authenticated role create permission on QuoteRequest. Add an afterCreate lifecycle hook to notify the sales team. Strapi 5 recommends Document Service middlewares for most data-layer customization, but lifecycle hooks remain supported and are a good fit for side effects like notifications that don't modify the response:
// src/api/quote-request/content-types/quote-request/lifecycles.ts
export default {
async afterCreate(event) {
const { result } = event;
if (!result.publishedAt) return; // skip drafts
try {
await strapi.plugin('email').service('email').send({
to: 'sales@yourcompany.com',
from: 'noreply@yourcompany.com',
subject: `New Quote Request: ${result.documentId}`,
text: `A new quote request has been submitted.`,
});
} catch (err) {
console.error('Quote notification failed:', err);
}
},
};The if (!result.publishedAt) return; guard on line 4 exists because in Strapi 5, lifecycle hooks can fire on draft saves, not just when content is published. When a Content-Type has Draft & Publish enabled, creating a published entry may trigger afterCreate for both the draft and published versions.
Without this guard, the sales team could receive duplicate email notifications for a single quote submission. Checking publishedAt filters out any draft-stage firings and sends exactly one email per quote. See the lifecycle hooks docs and the email plugin reference for the full event object and send options.
Cart State and Submit
The cart collects the products and quantities a buyer wants to include in their quote request before submission. A lightweight implementation using React Context and useState is sufficient at this scale, so there is no need for Redux or Zustand. The provider below exposes addItem, removeItem, and updateQuantity methods to every component in the tree:
// app/providers/cart-provider.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
type CartItem = { productDocumentId: string; name: string; price: number; quantity: number; minOrderQty: number };
type CartContextType = {
items: CartItem[];
addItem: (item: Omit<CartItem, 'quantity'>) => void;
removeItem: (productDocumentId: string) => void;
updateQuantity: (productDocumentId: string, quantity: number) => void;
};
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
function addItem(item: Omit<CartItem, 'quantity'>) {
setItems((prev) => {
const existing = prev.find((i) => i.productDocumentId === item.productDocumentId);
if (existing) return prev;
return [...prev, { ...item, quantity: item.minOrderQty }];
});
}
function removeItem(productDocumentId: string) {
setItems((prev) => prev.filter((i) => i.productDocumentId !== productDocumentId));
}
function updateQuantity(productDocumentId: string, quantity: number) {
setItems((prev) =>
prev.map((i) => (i.productDocumentId === productDocumentId ? { ...i, quantity } : i))
);
}
return (
<CartContext.Provider value={{ items, addItem, removeItem, updateQuantity }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
}Wrap your root layout with <CartProvider> so every page has access to the cart state. The "Add to quote" button on each product card calls addItem from the cart context, passing the product's documentId, name, price, and minOrderQty. The initial quantity defaults to minOrderQty.
The /quote route renders the cart as a table with columns for product name, unit price, quantity, and line total. Each row's quantity input should set min={item.minOrderQty} as an HTML attribute, giving browsers native validation before the form is submitted. The validateItems function below serves as the programmatic check before the request fires, catching cases where JavaScript updates the quantity below the threshold:
function validateItems(
items: { productDocumentId: string; quantity: number }[],
products: { documentId: string; minOrderQty: number }[]
) {
return items.every((item) => {
const product = products.find(
(p) => p.documentId === item.productDocumentId
);
return product && item.quantity >= product.minOrderQty;
});
}The submit handler posts to /api/quote-requests using the same fetchFromStrapi helper with method: 'POST'. The body structure includes the cart items and any buyer notes:
await fetchFromStrapi('/api/quote-requests', {
method: 'POST',
body: JSON.stringify({
data: {
items: items.map(({ productDocumentId, quantity }) => ({ productDocumentId, quantity })),
notes: formData.get('notes') ?? '',
},
}),
});On a 200 response, clear the cart state by calling removeItem on each entry, or resetting the items array to [] in a dedicated clearCart method, then redirect the buyer to /quote/confirmed with a confirmation message showing the quote reference ID.
What's Next for Your B2B Wholesale Catalog
You now have a login-gated B2B catalog with tier-specific pricing per buyer and a quote request flow replacing the standard cart checkout. Content editors stay in the Admin Panel while authorization lives in backend code.
How Strapi powered this:
- The Content-Type Builder models products, tiers, and categories without writing schema files;
- A repeatable
TierPricecomponent attaches multiple price points to one product record; - Custom policies and controller overrides personalize API responses per buyer tier;
- The Document Service API handles nested population for component relations;
- Users and Permissions roles map buyers to tiers with JWT-based auth out of the box;
- Lifecycle hooks on
QuoteRequesttrigger side effects like email notifications without modifying the response.
From here, consider adding order history, bulk reorder, a Salesforce CRM webhook, or field-level permissions to separate buyer and rep views.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.