BetaMaShop is in public beta. We improve it continuously, and your feedback shapes what comes next.

Add a backoffice page

Customizing & extendingEnglish· 6 min read· Updated July 01, 2026

Add a new route to your generated backoffice, gate it on a staff role, read your own Supabase data, and link it from the sidebar.

How-to

This page shows you how to add a new page to the backoffice that MaShop generates. The backoffice ships as a Next.js App Router project. Every page is a file, so adding a page means adding a folder with a page.tsx under the route group, gating it on a staff role, reading your own Supabase data, and linking it from the sidebar. You own this code, so you edit it directly in the workspace or in your own editor after you push to GitHub.

Where these files live. The backoffice routes sit under src/app/(backoffice)/. Each top-level folder is a page: dashboard, orders, products, customers, analytics, seo, coupons, blog, settings, plus catalog and customer sub-pages like categories, attributes, collections, inventory, segments, and reviews.

What a backoffice page is

A backoffice page is a folder inside src/app/(backoffice)/ that contains a page.tsx. (backoffice) is a route group: the parentheses mean the folder name does not appear in the URL. A file at src/app/(backoffice)/reviews/page.tsx serves the route /reviews, not /(backoffice)/reviews.

Pages default to Server Components. The template's own pages export const dynamic = 'force-dynamic' so they render per request against live data instead of being cached at build time. That export appears on 33 backoffice pages in the shipped template.

Every page renders inside the shared shell defined in src/app/(backoffice)/layout.tsx. That layout already calls requireSession('viewer'), mounts the fixed 248px dark sidebar at 900px and wider, the sticky header, and the sub-nav. You do not rebuild any of that per page.

Create the route file

Create the folder and file. For a page at /promotions:

src/app/(backoffice)/promotions/page.tsx

Start from the same shape the template's own pages use: an async default export, the role gate, and a container that matches the other pages.

import { requireStaffRole } from '@/lib/auth/require-staff-role';
import { getSettings } from '@/lib/settings/queries';

export const dynamic = 'force-dynamic';

export default async function PromotionsPage() {
 await requireStaffRole('viewer');
 const settings = await getSettings();

 return (<div className="px-4 sm:px-7 pt-6 pb-10 max-w-[1280px] mx-auto">
 <header className="mb-5">
 <h1 className="text-[26px] sm:text-[30px] font-bold tracking-[-0.03em] leading-[1.05]">
 Promotions
 </h1>
 </header>
 {/* your content */}
 </div>
 );
}

The @/ alias resolves to src/ (defined as "@/*": ["./src/*"] in tsconfig.json), so @/lib/auth/require-staff-role is src/lib/auth/require-staff-role.ts. The max-w-[1280px] mx-auto container and the header type sizes match the other pages so your new page lines up with the rest of the backoffice.

Gate the page on a staff role

Call requireStaffRole(min) at the top of the component before you read data. It reads the current staff session and redirects when the session is missing or the role is too low.

The role hierarchy is fixed in src/lib/auth/role-utils.ts: viewer = 1, editor = 2, admin = 3, owner = 4. A gate of requireStaffRole('viewer') lets any signed-in staff member in; requireStaffRole('admin') requires admin or owner.

CallWho passes
requireStaffRole('viewer')viewer, editor, admin, owner
requireStaffRole('editor')editor, admin, owner
requireStaffRole('admin')admin, owner
requireStaffRole('owner')owner only

The backoffice has no login form. Authentication is a first-party session handed off from MaShop. When the session is missing, requireStaffRole redirects to /auth/signed-out; when the role is too low, it redirects to /auth/signed-out?reason=insufficient_role. Because the shell layout already gates on viewer, the per-page call is what raises the bar for pages that need more than read access.

Read your data

The backoffice talks to your own Supabase project. Two server clients exist:

  • createAdminSupabase() from @/lib/supabase/admin uses SUPABASE_SERVICE_ROLE_KEY and bypasses row-level security. The template's query helpers use it in 51 places, because the page has already enforced the staff role before it reads.
  • createServerSupabase() from @/lib/supabase/server uses NEXT_PUBLIC_SUPABASE_ANON_KEY and runs under row-level security through the request cookies.

Keep the queries in a co-located file, not inline in page.tsx. The template puts them in a _lib/queries.ts or a _components/queries.ts next to the page (folders prefixed with _ are not routes). Start the query file with import 'server-only'; so it can never ship to the browser.

// src/app/(backoffice)/promotions/queries.ts
import 'server-only';
import { createAdminSupabase } from '@/lib/supabase/admin';

export async function listPromotions() {
 const supabase = createAdminSupabase();
 const { data, error } = await supabase.from('coupons').select('*').order('created_at', { ascending: false }).limit(500);
 if (error) throw error;
 return data ?? [];
}

Then call it from the page and pass the result down. When you fetch multiple things, run them together:

const [promotions, settings] = await Promise.all([
 listPromotions(),
 getSettings(),
]);

Show an honest empty state. A new store has no rows. Do not fake numbers. Render an empty state when the query returns zero rows, the way the Reviews page does by checking a real count before deciding between the empty and the populated view.

Handle URL parameters

When your page filters by a query string (for example /promotions?status=active), accept searchParams as a promise and validate the value against a known set before you use it. The Reviews page does this: it keeps a Set of allowed values and drops anything else, so a hand-typed parameter cannot reach the query.

const STATUSES = new Set(['active', 'expired']);

export default async function PromotionsPage({
 searchParams,
}: {
 searchParams: Promise<Record<string, string | undefined>>;
}) {
 await requireStaffRole('viewer');
 const params = await searchParams;
 const status = params.status && STATUSES.has(params.status) ? params.status: undefined;
 //...
}

Add client interactivity when you need it

Keep page.tsx a Server Component. When part of the page needs state, events, or a Supabase write from the browser, put that in a separate 'use client' component under a _components/ folder and pass server data into it as props. That is how every interactive page in the template is built: the server page fetches, an *Client component renders the interactive UI. In a client component, read from the browser with createBrowserSupabase() from @/lib/supabase/browser, which uses the anon key under row-level security.

Link the page from the navigation

A route works as soon as the file exists, but nothing points to it yet. Add it to the sidebar so staff can reach it.

The sidebar is data-driven from src/lib/nav/sidebar-nav.ts. It defines three groups: Overview (Dashboard, Orders, Products, Customers), Growth (Analytics, SEO, Discounts, Blog), and System (Settings). Add an item to the group you want:

import { Ticket } from '@/lib/icons';

{ id: 'promotions', labelKey: 'nav.discounts', href: '/promotions', icon: Ticket },

Icons come from @/lib/icons, the single re-export point the template uses. The labelKey must be an existing nav.* translation key; add a new key to the dictionary if none fits. The desktop sidebar, the mobile drawer, and the Cmd+K page search all read sidebar-nav.ts, so one edit covers all three.

If your page belongs to the Products, Customers, or Blog family, also add it to the sticky sub-nav in src/lib/nav/subnavs.ts. That file lists the sub-nav tabs and the route prefixes that activate them; the sub-nav renders only on those three families, and nowhere else.

Verify it works

  1. Open the new URL (/promotions) in the workspace preview. The page renders inside the shell.
  2. Confirm the sidebar link appears in the group you edited and highlights when you are on the page.
  3. Check the role gate: a page that calls requireStaffRole('admin') should redirect a viewer to /auth/signed-out?reason=insufficient_role.
  4. Confirm the data is real. On an empty store you should see your empty state, not zeros or placeholder rows.

Prompt the AI instead of hand-editing. You can describe the page in the chat and let the AI generate the route, the query helper, and the sidebar entry for you. Everything above is the shape it follows, so knowing it helps you review and adjust what the AI produces. Your prompts and generated code are not sent to OpenAI or Anthropic.

Next steps

customizingpagescode
Was this page helpful?
Your feedback is anonymous.