██╗   ██╗███████╗██████╗ ███████╗██╗   ██╗ ██████╗ ██╗██████╗
██║   ██║██╔════╝██╔══██╗██╔════╝██║   ██║██╔═══██╗██║██╔══██╗
██║   ██║█████╗  ██████╔╝█████╗  ██║   ██║██║   ██║██║██████╔╝
╚██╗ ██╔╝██╔══╝  ██╔══██╗██╔══╝  ╚██╗ ██╔╝██║   ██║██║██╔══██╗
 ╚████╔╝ ███████╗██║  ██║███████╗ ╚████╔╝ ╚██████╔╝██║██║  ██║
  ╚═══╝  ╚══════╝╚═╝  ╚═╝╚══════╝  ╚═══╝   ╚═════╝ ╚═╝╚═╝  ╚═╝

CMS Setup Guide

Add content-managed pages to a Next.js site with an authenticated admin editor. This guide produces a working CMS with Google OAuth, protected admin routes, a visual page editor, and public content rendering — all from Verevoir packages.

The pattern described here is used by the QR Links Service and can be applied to any Next.js App Router project.

Prerequisites

npm install @verevoir/schema @verevoir/editor @verevoir/access @verevoir/storage google-auth-library

Overview

src/
├── controls/          # Content controls (schema + renderer per block type)
│   ├── types.ts       # ContentControl and ContentBlock interfaces
│   ├── hero.tsx        # Example control
│   └── index.ts       # Registry — controls map + controlList
├── server/
│   ├── auth.ts         # Auth adapter factory (test + Google OAuth)
│   ├── db.ts           # PostgresAdapter singleton
│   ├── roles.ts        # Role store singleton
│   ├── require-admin.ts # Route guard
│   └── content.ts      # Page data access
├── actions/
│   ├── auth.ts         # Server actions: resolveToken, getTestAccounts, getAuthMode
│   └── pages.ts        # Server actions: page CRUD (guarded)
├── context/
│   └── UserContext.tsx  # Client auth state provider
├── components/
│   ├── AuthButton.tsx              # Sign-in UI (test accounts + Google)
│   └── ContentBlockEditor.tsx      # BlockEditor wrapper with move/remove controls
└── app/
    ├── layout.tsx       # Root layout with UserProvider
    └── admin/
        ├── layout.tsx   # Protected layout (requireAdmin guard)
        ├── page.tsx     # Admin dashboard
        └── pages/
            ├── page.tsx          # Page list
            └── [slug]/
                ├── page.tsx      # Page editor route
                └── PageEditor.tsx # Editor client component

Step 1: Define Content Controls

A content control bundles a schema (for editing), a renderer (for display), and a type discriminator (for storage).

Types

// src/controls/types.ts
import type { ComponentType } from 'react';

export interface ContentControl {
  type: string;
  label: string;
  block: any;
  Renderer: ComponentType<{ data: any }>;
}

export interface ContentBlock {
  type: string;
  [key: string]: unknown;
}

Example Control

// src/controls/hero.tsx
import { defineBlock, text } from '@verevoir/schema';
import type { ContentControl } from './types';

const block = defineBlock({
  name: 'content-hero',
  fields: {
    heading: text('Heading'),
    subheading: text('Subheading'),
    ctaText: text('CTA Text').optional(),
    ctaHref: text('CTA Link').optional(),
  },
});

function Renderer({ data }: { data: { heading: string; subheading: string } }) {
  return (
    <section>
      <h1>{data.heading}</h1>
      <p>{data.subheading}</p>
    </section>
  );
}

export const hero: ContentControl = {
  type: 'hero',
  label: 'Hero',
  block,
  Renderer,
};

All field types from @verevoir/schema work: text, select, number, boolean, richText, array, object, reference. The editor renders appropriate inputs for each.

Registry

// src/controls/index.ts
import { hero } from './hero';
import { textBlock } from './text-block';
import { divider } from './divider';
import type { ContentControl } from './types';

export type { ContentControl, ContentBlock } from './types';

export const controls: Record<string, ContentControl> = {
  hero,
  text: textBlock,
  divider,
};

export const controlList: ContentControl[] = Object.values(controls);

The controls map is keyed by type discriminator. controlList is the ordered list shown in the block picker.

Step 2: Set Up Authentication

Auth Adapter

Dual-mode: test accounts for local development, Google OAuth for production. The mode is determined by the AUTH_MODE environment variable.

// src/server/auth.ts
import { createTestAuthAdapter } from '@verevoir/access/test-accounts';
import type { TestAccount } from '@verevoir/access/test-accounts';
import type { Identity } from '@verevoir/access';

const testAccounts: TestAccount[] = [
  {
    token: 'admin-token',
    identity: {
      id: 'test-admin-1',
      roles: ['admin'],
      metadata: { email: 'admin@example.com', name: 'Admin User' },
    },
  },
  {
    token: 'user-token',
    identity: {
      id: 'test-user-1',
      roles: ['user'],
      metadata: { email: 'user@example.com', name: 'Regular User' },
    },
  },
];

function createAuth() {
  if (process.env.AUTH_MODE === 'test') {
    return createTestAuthAdapter({ accounts: testAccounts });
  }

  // Production: Google OAuth with lazy imports
  const adapter = {
    async resolve(token: string | null): Promise<Identity | null> {
      if (!token) return null;
      const { createGoogleAuthAdapter } = await import('@verevoir/access/google');
      const { OAuth2Client } = await import('google-auth-library');
      const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID!);

      // Role resolution: database if available, SEED_ADMIN_ID fallback otherwise
      const resolveRoles = process.env.DATABASE_URL
        ? async (sub: string) => {
            const { getRoleStore } = await import('./roles');
            const roleStore = await getRoleStore();
            const roles = await roleStore.getRoles(sub);
            return roles.length > 0 ? roles : ['user'];
          }
        : async (sub: string) => {
            return sub === process.env.SEED_ADMIN_ID ? ['admin'] : ['user'];
          };

      const googleAuth = createGoogleAuthAdapter({
        client: client as unknown as Parameters<
          typeof createGoogleAuthAdapter
        >[0]['client'],
        allowedClientIds: [process.env.GOOGLE_CLIENT_ID!],
        hostedDomain: process.env.GOOGLE_HOSTED_DOMAIN || undefined,
        mapRoles: async (payload) => {
          const sub = `google-${payload.sub ?? ''}`;
          return resolveRoles(sub);
        },
      });
      return googleAuth.resolve(token);
    },
  };
  return adapter;
}

export const auth = createAuth();
export { testAccounts };

Database and Role Store

Only needed when DATABASE_URL is set (production with persistent role assignments).

// src/server/db.ts
import { PostgresAdapter } from '@verevoir/storage';

const storage = new PostgresAdapter({
  connectionString: process.env.DATABASE_URL!,
});

let initialized = false;

export async function ensureDb() {
  if (!initialized) {
    await storage.connect();
    await storage.migrate();
    initialized = true;
  }
  return storage;
}
// src/server/roles.ts
import { createRoleStore } from '@verevoir/access/role-store';
import { ensureDb } from './db';

type RoleStore = Awaited<ReturnType<typeof createRoleStore>>;
let roleStore: RoleStore | null = null;

export async function getRoleStore() {
  if (!roleStore) {
    const storage = await ensureDb();
    roleStore = createRoleStore({
      storage,
      seedAdmin: {
        userId: process.env.SEED_ADMIN_ID!,
        roles: ['admin'],
      },
    });
  }
  return roleStore;
}

Route Guard

// src/server/require-admin.ts
import type { Identity } from '@verevoir/access';
import { auth } from '@/server/auth';

export async function requireAdmin(token: string | null): Promise<Identity> {
  let identity: Identity | null = null;
  try {
    identity = await auth.resolve(token);
  } catch {
    // Auth resolution failed
  }
  if (!identity || !identity.roles.includes('admin')) {
    throw new Error('Unauthorized');
  }
  return identity;
}

Auth Server Actions

// src/actions/auth.ts
'use server';

import { auth, testAccounts } from '@/server/auth';
import type { Identity } from '@verevoir/access';

export async function resolveToken(token: string | null): Promise<Identity | null> {
  try {
    return await auth.resolve(token);
  } catch {
    return null;
  }
}

export async function getTestAccounts() {
  if (process.env.AUTH_MODE !== 'test') return [];
  return testAccounts.map((a) => ({
    token: a.token,
    name: a.identity.metadata?.name as string,
    email: a.identity.metadata?.email as string,
    roles: a.identity.roles,
  }));
}

export async function getAuthMode(): Promise<string> {
  return process.env.AUTH_MODE ?? 'google';
}

Client Auth Context

// src/context/UserContext.tsx
'use client';

import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { Identity } from '@verevoir/access';
import { ANONYMOUS, isAnonymous } from '@verevoir/access';
import { resolveToken } from '@/actions/auth';

interface UserContextValue {
  identity: Identity;
  isAuthenticated: boolean;
  isLoading: boolean;
  isAdmin: boolean;
  signIn: (token: string) => void;
  signOut: () => void;
}

const UserContext = createContext<UserContextValue | null>(null);

function readCookieToken(): string | null {
  if (typeof document === 'undefined') return null;
  const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/);
  return match ? decodeURIComponent(match[1]) : null;
}

export function UserProvider({ children }: { children: React.ReactNode }) {
  const [token, setToken] = useState<string | null>(readCookieToken);
  const [identity, setIdentity] = useState<Identity>(ANONYMOUS);
  const [isLoading, setIsLoading] = useState(() => readCookieToken() !== null);

  useEffect(() => {
    if (!token) return;
    resolveToken(token)
      .then((id) => {
        if (id) {
          setIdentity(id);
        } else {
          document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
          setToken(null);
          setIdentity(ANONYMOUS);
        }
      })
      .catch(() => {
        document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
        setToken(null);
        setIdentity(ANONYMOUS);
      })
      .finally(() => setIsLoading(false));
  }, [token]);

  const signIn = useCallback((t: string) => {
    document.cookie = `auth-token=${encodeURIComponent(t)}; path=/; SameSite=Lax`;
    setIsLoading(true);
    setToken(t);
  }, []);

  const signOut = useCallback(() => {
    document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
    setToken(null);
    setIdentity(ANONYMOUS);
  }, []);

  return (
    <UserContext.Provider
      value={{
        identity,
        isAuthenticated: !isAnonymous(identity),
        isLoading,
        isAdmin: identity.roles.includes('admin'),
        signIn,
        signOut,
      }}
    >
      {children}
    </UserContext.Provider>
  );
}

export function useUser(): UserContextValue {
  const ctx = useContext(UserContext);
  if (!ctx) throw new Error('useUser must be used within a UserProvider');
  return ctx;
}

Auth Button

// src/components/AuthButton.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { useUser } from '@/context/UserContext';
import { getTestAccounts, getAuthMode } from '@/actions/auth';

export function AuthButton() {
  const { identity, isAuthenticated, isLoading, signIn, signOut } = useUser();
  const [accounts, setAccounts] = useState([]);
  const [authMode, setAuthMode] = useState<string | null>(null);
  const googleButtonRef = useRef<HTMLDivElement>(null);
  const [gisKey, setGisKey] = useState(0);

  useEffect(() => {
    getAuthMode().then(setAuthMode);
    getTestAccounts().then(setAccounts);
  }, []);

  // Google Identity Services initialization
  useEffect(() => {
    if (authMode !== 'google' || isAuthenticated || isLoading) return;
    const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
    if (!clientId) return;

    function initGis() {
      if (!window.google?.accounts?.id || !googleButtonRef.current) return;
      window.google.accounts.id.initialize({
        client_id: clientId!,
        callback: (response) => signIn(response.credential),
      });
      window.google.accounts.id.renderButton(googleButtonRef.current, {
        theme: 'outline',
        size: 'large',
      });
    }

    if (window.google?.accounts?.id) {
      initGis();
    } else {
      const interval = setInterval(() => {
        if (window.google?.accounts?.id) {
          clearInterval(interval);
          initGis();
        }
      }, 100);
      return () => clearInterval(interval);
    }
  }, [authMode, isAuthenticated, isLoading, signIn, gisKey]);

  if (authMode === null) return null;

  // Test mode: show account buttons
  if (authMode === 'test') {
    if (isAuthenticated) {
      return (
        <div>
          <span>{(identity.metadata?.name as string) || identity.id}</span>
          <button onClick={() => { signOut(); window.location.href = '/'; }}>
            Sign out
          </button>
        </div>
      );
    }
    return (
      <div>
        {accounts.map((a) => (
          <button key={a.token} onClick={() => signIn(a.token)}>
            {a.name} ({a.roles.join(', ')})
          </button>
        ))}
      </div>
    );
  }

  // Google mode
  if (isAuthenticated) {
    return (
      <div>
        <span>{(identity.metadata?.name as string) || identity.id}</span>
        <button onClick={() => {
          window.google?.accounts.id.disableAutoSelect();
          window.google?.accounts.id.cancel();
          signOut();
          setGisKey((k) => k + 1);
          window.location.href = '/';
        }}>
          Sign out
        </button>
      </div>
    );
  }

  if (isLoading) return null;
  return <div key={gisKey} ref={googleButtonRef} />;
}

Step 3: Root Layout

Wrap the app in UserProvider and include the Google Identity Services script.

// src/app/layout.tsx
import { UserProvider } from '@/context/UserContext';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <script src="https://accounts.google.com/gsi/client" async defer />
      </head>
      <body>
        <UserProvider>
          <Header />
          <main>{children}</main>
        </UserProvider>
      </body>
    </html>
  );
}

The Header component can use useUser() to show/hide navigation items based on auth state and show the AuthButton.

Step 4: Protected Admin Layout

// src/app/admin/layout.tsx
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
import { requireAdmin } from '@/server/require-admin';
import { UserProvider } from '@/context/UserContext';

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth-token')?.value ?? null;

  try {
    await requireAdmin(token);
  } catch {
    notFound();
  }

  return <UserProvider>{children}</UserProvider>;
}

Unauthenticated or non-admin users see a 404. The UserProvider here is redundant if the root layout already provides it, but harmless — it ensures admin routes always have auth context.

Step 5: Page Editor

Content Store

Start with static seed data or in-memory storage. Swap to PostgresAdapter for production.

// src/server/content.ts
export interface PageData {
  title: string;
  slug: string;
  description?: string;
  content: string; // JSON-encoded ContentBlock[]
}

const pages = new Map<string, PageData>();

export async function getPageBySlug(slug: string): Promise<PageData | null> {
  return pages.get(slug) ?? null;
}

export async function listPages(): Promise<PageData[]> {
  return [...pages.values()];
}

export async function savePage(page: PageData): Promise<void> {
  pages.set(page.slug, page);
}

Page Server Actions

// src/actions/pages.ts
'use server';

import { cookies } from 'next/headers';
import { requireAdmin } from '@/server/require-admin';
import { getPageBySlug, listPages, savePage } from '@/server/content';

async function guardAdmin() {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth-token')?.value ?? null;
  await requireAdmin(token);
}

export async function getAllPages() {
  await guardAdmin();
  return listPages();
}

export async function updatePage(slug: string, data: Partial<PageData>) {
  await guardAdmin();
  const existing = await getPageBySlug(slug);
  if (!existing) throw new Error(`Page not found: ${slug}`);
  const updated = { ...existing, ...data };
  await savePage(updated);
  return updated;
}

ContentBlockEditor

Wraps BlockEditor from @verevoir/editor with move/remove controls for each block.

// src/components/ContentBlockEditor.tsx
'use client';

import { BlockEditor } from '@verevoir/editor';
import '@verevoir/editor/styles/editor-form.css';
import type { ContentControl, ContentBlock } from '@/controls';

interface Props {
  control: ContentControl;
  data: ContentBlock;
  index: number;
  total: number;
  onChange: (data: ContentBlock) => void;
  onRemove: () => void;
  onMove: (direction: -1 | 1) => void;
}

export function ContentBlockEditor({
  control,
  data,
  index,
  total,
  onChange,
  onRemove,
  onMove,
}: Props) {
  const { type, ...blockData } = data;

  return (
    <div>
      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
        <span style={{ fontWeight: 600 }}>{control.label}</span>
        <div style={{ display: 'flex', gap: '0.375rem' }}>
          <button onClick={() => onMove(-1)} disabled={index === 0}>&#9650;</button>
          <button onClick={() => onMove(1)} disabled={index === total - 1}>&#9660;</button>
          <button onClick={onRemove}>Remove</button>
        </div>
      </div>
      <div data-editor-form>
        <BlockEditor
          block={control.block}
          value={blockData}
          onChange={(value) => onChange({ type, ...value })}
        />
      </div>
    </div>
  );
}

The data-editor-form attribute is required — editor-form.css scopes its styles to elements with this attribute. Without it, BlockEditor fields render unstyled.

Page Editor Route

// src/app/admin/pages/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPageBySlug } from '@/server/content';
import { PageEditor } from './PageEditor';

export default async function AdminPageEditorPage({ params }) {
  const { slug } = await params;
  const page = await getPageBySlug(slug);
  if (!page) notFound();
  return <PageEditor page={page} />;
}
// src/app/admin/pages/[slug]/PageEditor.tsx
'use client';

import { useState } from 'react';
import { ContentBlockEditor } from '@/components/ContentBlockEditor';
import { updatePage } from '@/actions/pages';
import { controls, controlList } from '@/controls';
import type { ContentBlock } from '@/controls';

export function PageEditor({ page }) {
  const [title, setTitle] = useState(page.title);
  const [blocks, setBlocks] = useState<ContentBlock[]>(() => JSON.parse(page.content));
  const [saving, setSaving] = useState(false);

  const handleSave = async () => {
    setSaving(true);
    try {
      await updatePage(page.slug, { title, content: JSON.stringify(blocks) });
    } finally {
      setSaving(false);
    }
  };

  const updateBlock = (index: number, data: ContentBlock) => {
    setBlocks((prev) => prev.map((b, i) => (i === index ? data : b)));
  };

  const removeBlock = (index: number) => {
    setBlocks((prev) => prev.filter((_, i) => i !== index));
  };

  const moveBlock = (index: number, direction: -1 | 1) => {
    setBlocks((prev) => {
      const next = [...prev];
      const target = index + direction;
      [next[index], next[target]] = [next[target], next[index]];
      return next;
    });
  };

  const addBlock = (type: string) => {
    setBlocks((prev) => [...prev, { type }]);
  };

  return (
    <div>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button onClick={handleSave} disabled={saving}>
        {saving ? 'Saving...' : 'Save'}
      </button>

      {blocks.map((block, i) => {
        const control = controls[block.type];
        if (!control) return null;
        return (
          <ContentBlockEditor
            key={i}
            control={control}
            data={block}
            index={i}
            total={blocks.length}
            onChange={(data) => updateBlock(i, data)}
            onRemove={() => removeBlock(i)}
            onMove={(dir) => moveBlock(i, dir)}
          />
        );
      })}

      <div>
        {controlList.map((c) => (
          <button key={c.type} onClick={() => addBlock(c.type)}>
            + {c.label}
          </button>
        ))}
      </div>
    </div>
  );
}

Step 6: Public Content Rendering

Render content blocks on public pages using the same controls registry.

// src/components/ContentBlocks.tsx
'use client';

import { controls } from '@/controls';
import type { ContentBlock } from '@/controls';

export function ContentBlocks({ blocks }: { blocks: ContentBlock[] }) {
  return (
    <>
      {blocks.map((block, i) => {
        const control = controls[block.type];
        if (!control) return null;
        const { Renderer } = control;
        return <Renderer key={i} data={block} />;
      })}
    </>
  );
}
// src/app/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPageBySlug } from '@/server/content';
import { ContentBlocks } from '@/components/ContentBlocks';

export default async function SlugPage({ params }) {
  const { slug } = await params;
  const page = await getPageBySlug(slug);
  if (!page) notFound();
  const blocks = JSON.parse(page.content);
  return <ContentBlocks blocks={blocks} />;
}

The same control renderers are used for both admin preview and public display. Edit once, render consistently.

Environment Variables

Local Development (test auth)

AUTH_MODE=test

No other env vars needed. Test account buttons appear in the header.

Local Development (Google OAuth)

GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
SEED_ADMIN_ID=google-123456789

Add http://localhost:<port> as an authorized JavaScript origin in the Google Cloud Console. SEED_ADMIN_ID is your Google sub claim prefixed with google- — it gives you the admin role without a database.

Production

GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
SEED_ADMIN_ID=google-123456789
DATABASE_URL=postgres://user:password@host:5432/dbname

With DATABASE_URL set, roles are stored persistently via @verevoir/access/role-store backed by PostgresAdapter. The seed admin is created automatically on first startup.

Upgrading to Editor Premium

@verevoir/editor-premium adds inline editing with hover overlays, a block picker modal, and an edit panel. It wraps the same BlockEditor and controls registry documented here. If you have access to the premium package, replace the manual block list in PageEditor with InteractiveContentBlocks from @verevoir/editor-premium — the controls, auth, and content layers remain unchanged.

Checklist

  1. Create src/controls/ with at least one control (type + schema + renderer)
  2. Create src/controls/index.ts with controls map and controlList
  3. Create src/server/auth.ts with test accounts and Google OAuth adapter
  4. Create src/server/require-admin.ts guard
  5. Create src/actions/auth.ts server actions
  6. Create src/context/UserContext.tsx provider
  7. Create src/components/AuthButton.tsx
  8. Add UserProvider and GIS script to root layout
  9. Create src/app/admin/layout.tsx with requireAdmin guard
  10. Create src/components/ContentBlockEditor.tsx (imports @verevoir/editor/styles/editor-form.css, wraps in data-editor-form)
  11. Create page editor route (admin/pages/[slug]) with block list and add buttons
  12. Create public content route ([slug]/page.tsx with ContentBlocks)
  13. Add Google OAuth origin in Cloud Console
  14. Set environment variables

Further Reading