Documentation

Embed the build studio

Add an in-app page editor to your Next.js site using base-app's build-studio module.

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/page updates
  • 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-renderer package — the integration is file-copy based)

Step-by-step

  1. 1

    Copy the build-studio module

    From base-app/src/build-studio/ copy:

    • client-renderer.tsx — the runtime renderer that supports edit mode
    • virtualdom-renderer.tsx — virtual-DOM diffing
    • selective-virtual-dom.ts + selective-virtual-helpers.ts — selective hydration
    • attributes-map.tsx — element prop mapping
    • variant-engine.ts + html-variant-engine.ts — variant resolution
    • animate-runtime.ts — animation runtime
    • html-format.ts + html-renderer.tsx — raw-HTML page support
    • data-container.tsx — data-bound component containers
    • pagination-controls.tsx, with-virtual-dom-config.tsx — utilities
    • buttons/, preset/, styled-element/ — shared primitives

    Put them under src/build-studio/ in your project. The module has zero hard dependencies on base-app internals beyond your lib/ (which you already have if you followed the auth tutorial).

  2. 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 an editable flag:

    // 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 editable is 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. 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 saveEdit 800ms 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=true on the client still can't write — AppEngine returns 403.

  4. 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-gated
    

    Add a UI affordance — a "Edit this page" button visible only to admins — that links to /(edit)/....

  5. 5

    Lock down the routes

    The (edit) route group needs hard server-side gating. Don't trust editable flags 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. 6

    Wire dynamic asset loading

    Pages in the AppEngine CMS can pull custom CSS, fonts, scripts. The studio uses the same analyzeDynamicAssets helper 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. 7

    Test the round trip

    1. Open /your-page?edit=1 as an admin. The studio inspector appears.
    2. Edit a heading. The edit persists locally and the inspector marks it dirty.
    3. Wait 1s — the auto-save fires. Network tab shows POST /site/page-update.
    4. Reload the page without ?edit=1. The change is live.
    5. Open the page as an anonymous visitor. Same change appears.
    6. 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-update should return 200. If 403, your role lacks site:page:write.

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