base-app/src/build-studio/ is a runtime page editor — open it on any page rendered from AppEngine's CMS, drag-edit the components, save back. Embedding it in a customer-facing app gives admins a way to tweak content without leaving the site. This tutorial shows how to drop the studio in and gate it to authorized users.
What it gives you
- Inline editing of any AppEngine-rendered page
- Live preview — edits visible in the page as you make them
- Persistence back to AppEngine via
site/pageupdates - Same render pipeline as the admin's Studio Builder, but mounted on your domain
The build-studio renders the page through the same virtual-DOM the runtime uses, then layers an editor mode on top. So whatever you can build in the admin Studio Builder, you can edit in-place in the customer app.
When to embed it
- A SaaS where each customer has their own pages (multi-brand storefronts, white-labelled portals)
- A CMS-driven marketing site where editors prefer in-context edits to the back-of-house admin
- A demo/preview environment for prospective customers
If your editing happens entirely in the admin UI, you don't need this — just send people to https://admin.appmint.io.
Prerequisites
- Working AppEngine integration with dynamic page rendering
- Auth in place — only Admin/Editor roles should see the studio
- The base-app reference repo cloned locally so you can copy files from
base-app/src/build-studio/(there is no@appmint/client-rendererpackage — the integration is file-copy based)
Step-by-step
- 1
Copy the build-studio module
From
base-app/src/build-studio/copy:client-renderer.tsx— the runtime renderer that supports edit modevirtualdom-renderer.tsx— virtual-DOM diffingselective-virtual-dom.ts+selective-virtual-helpers.ts— selective hydrationattributes-map.tsx— element prop mappingvariant-engine.ts+html-variant-engine.ts— variant resolutionanimate-runtime.ts— animation runtimehtml-format.ts+html-renderer.tsx— raw-HTML page supportdata-container.tsx— data-bound component containerspagination-controls.tsx,with-virtual-dom-config.tsx— utilitiesbuttons/,preset/,styled-element/— shared primitives
Put them under
src/build-studio/in your project. The module has zero hard dependencies onbase-appinternals beyond yourlib/(which you already have if you followed the auth tutorial). - 2
Mount the renderer with edit mode
Your dynamic page route already calls
getPage()and renders the result. Swap the renderer for the build-studio variant and pass aneditableflag:// src/app/[[...slug]]/page.tsx import { headers } from 'next/headers'; import { getAppEngineClient } from '@/lib/appmint-client'; import { ClientRenderer } from '@/build-studio/client-renderer'; import { activeSession } from '@/lib/active-session'; export default async function DynamicPage({ params, }: { params: Promise<{ slug?: string[] }>; }) { const { slug = [] } = await params; const headersList = await headers(); const hostName = headersList.get('host') || ''; const client = getAppEngineClient(); const site = await client.getSite(hostName); const page = await client.getPage(hostName, site!.data.name, slug.join('/') || 'home'); const user = await activeSession.getUser(); const canEdit = user?.roles?.some((r: string) => ['Admin', 'ContentAdmin', 'Editor'].includes(r), ); return ( <ClientRenderer page={page} site={site} editable={canEdit} /> ); }When
editableis true, the renderer wraps each component in a hover affordance. Click → an inspector docks to the side; drag-handles let you reorder. When false, the page renders as a normal site visitor sees it. - 3
Persist edits
The studio sends save requests on every committed change. Wire that up to AppEngine's site update endpoint:
// inside src/build-studio/client-renderer.tsx — already wired by base-app pattern import { getAppEngineClient } from '@/lib/appmint-client'; async function saveEdit(siteId: string, pageId: string, content: any) { const client = getAppEngineClient(); await client.processRequest('post', 'site/page-update', { siteId, pageId, content, }); }The studio batches changes — by default it fires
saveEdit800ms after the last edit. To force immediate saves, the studio exposes a "Publish" button.Permissions on the endpoint are server-enforced. A non-admin who somehow gets
editable=trueon the client still can't write — AppEngine returns 403. - 4
Add a toggle for edit mode
Even admins don't want the editor up by default. Add a query-string toggle:
// src/app/[[...slug]]/page.tsx const editable = canEdit && new URLSearchParams(headersList.get('referer')?.split('?')[1] || '').get('edit') === '1';Or — better — a route group like
(edit)/[[...slug]]for the studio, with a regular[[...slug]]for everyone else. Same source page, two render paths.src/app/ [[...slug]]/page.tsx # public render, no studio (edit)/[[...slug]]/page.tsx # studio render, role-gatedAdd a UI affordance — a "Edit this page" button visible only to admins — that links to
/(edit)/.... - 5
Lock down the routes
The (edit) route group needs hard server-side gating. Don't trust
editableflags on the client:// src/app/(edit)/middleware.ts (or in src/middleware.ts with a path matcher) import { NextResponse } from 'next/server'; import { activeSession } from '@/lib/active-session'; export async function middleware(req: NextRequest) { if (!req.nextUrl.pathname.startsWith('/edit/')) return NextResponse.next(); const user = await activeSession.getUserFromRequest(req); if (!user || !user.roles?.some((r) => ['Admin', 'ContentAdmin', 'Editor'].includes(r))) { return NextResponse.redirect(new URL('/login', req.url)); } return NextResponse.next(); }Combine with AppEngine-side permission checks — the role list above only matters if AppEngine actually returns the role on whoami.
- 6
Wire dynamic asset loading
Pages in the AppEngine CMS can pull custom CSS, fonts, scripts. The studio uses the same
analyzeDynamicAssetshelper as the public renderer:import { analyzeDynamicAssets } from '@/app/[[...slug]]/page-assets'; // ... const dynamicAssets = analyzeDynamicAssets(page, site); return ( <> {dynamicAssets?.styles.map((href) => ( <link key={href} rel="stylesheet" href={href} /> ))} <ClientRenderer page={page} site={site} editable={canEdit} /> {dynamicAssets?.scripts.map((src) => ( <script key={src} src={src} async /> ))} </> );Without this, third-party libs the page depends on (the calendar component, charts, custom widgets) won't load.
- 7
Test the round trip
- Open
/your-page?edit=1as an admin. The studio inspector appears. - Edit a heading. The edit persists locally and the inspector marks it dirty.
- Wait 1s — the auto-save fires. Network tab shows
POST /site/page-update. - Reload the page without
?edit=1. The change is live. - Open the page as an anonymous visitor. Same change appears.
- Open the AppEngine admin → Site → Pages. The change is reflected there.
If step 4 fails (no change visible), check that AppEngine accepted the write —
POST /site/page-updateshould return 200. If 403, your role lackssite:page:write. - Open
What you don't get
The build-studio is the renderer + inline edit affordances. It's not the full Studio Builder — there's no separate page tree, no template browser, no media library. For those, link out to the admin UI:
<a
href={`https://admin.appmint.io/sites/${site.id}/pages/${pageId}`}
target="_blank"
>
Open full editor
</a>
The split is deliberate — the embedded studio handles 80% of edits without leaving the customer app, and the rest are heavy enough to warrant the dedicated tool.
What's next
- Studio Builder overview — the full editor in the admin UI.
- Dynamic pages from the CMS — the runtime renderer the studio extends.
- Activity tracking — see who's editing what.