📋 Reference

MARLEY TEMPLATE

Multi-brand Next.js architecture — full feature reference

What the template contains, how each system works, and five proposed additions. For developers onboarding to the codebase or planning extensions.

Version 1.0 | March 2026 | Reviewed by Dev the Dev
Version 1.0 | March 2026 | Reviewed by Dev the Dev
What this document is A reference for the Marley multi-brand Next.js template. It covers what the template contains, how each system is structured, the full database schema, the route map, and the environment variables required for deployment. It closes with five proposed future additions. Use this when navigating an unfamiliar part of the codebase, planning a new feature, or onboarding a second developer.

1. What Marley is

Marley is a production-grade Next.js site template that proves its own flexibility by wearing different costumes. The same codebase is styled for multiple fictional businesses from public domain literature — each with a distinct voice, palette, and copy — without touching routing, components, or infrastructure.

The template demonstrates itself. Each brand instance is a stress test: if Scrooge & Marley's austere ledger aesthetic and Au Bonheur des Dames' lush retail warmth can coexist in the same codebase, the theming system is real.

The base codebase was derived from the Medhavy adaptive learning platform (Medhavy LLC, Nik Bear Brown and Srinivas Sridhar). All Medhavy branding has been replaced per brand instance. The infrastructure — routing, admin, database schema, API contracts — is shared and unchanged across instances.

Current brand instances

Brand Source Industry (fictional) Status
Scrooge & Marley Dickens, A Christmas Carol, 1843 Counting house, money lending Live
Au Bonheur des Dames Zola, Au Bonheur des Dames, 1883 Department store, retail Planned
Lapham Paint Howells, The Rise of Silas Lapham, 1885 Industrial paint manufacturing Planned
Dotheboys Hall Dickens, Nicholas Nickleby, 1839 Education (cautionary) Planned

All source works are public domain. The brands as implemented — copy, design, codebase — are not.

2. Tech stack

LayerTechnologyPurpose
Framework Next.js (App Router) Routing, SSR, API routes, middleware
Deployment Vercel Auto-deploy on push to main. GitHub integration.
Styling Tailwind CSS + @tailwindcss/typography Utility classes + prose rendering for blog and articles
Language TypeScript Full type coverage across components, API routes, and lib
Theming next-themes Light/dark mode toggle with system preference support
Database Neon (serverless PostgreSQL) Tools, blog posts, Substack sections and articles
File storage Vercel Blob Cover images and inline images uploaded via the blog editor
Rich text editor Tiptap (ProseMirror) Blog post authoring — WYSIWYG, embeds, D3 viz placeholders
Data visualisation D3.js Charts embedded in blog posts via data-viz placeholders
ZIP parsing adm-zip Server-side parsing of Substack export ZIPs
UI components shadcn/ui (60+ components) Dialogs, forms, badges, buttons — in components/ui/

3. Multi-brand theming system

The theming system is the core architectural claim of the Marley template. Changing a brand requires editing three files. No component changes. No routing changes. The entire site repaints.

The three files that must stay in sync

lib/theme.ts
TypeScript source of truth

Exports a typed theme constant containing the brand name, tagline, address, contact, domain, and the eight colour values (bb1bb8). This is the canonical source. If it conflicts with the other two files, this one wins.

public/theme.json
Machine-readable

Same data as lib/theme.ts, serialised as JSON. Read by Indiana (the doc generator) and any external tooling that needs palette values without importing TypeScript. Includes a colorRoles field describing the semantic role of each colour variable.

app/globals.css
CSS variables

The :root block defines --bb-1 through --bb-8. A matching .dark block inverts the parchment/soot relationship for dark mode. All components reference these variables — no hex values appear in component files.

Palette variable roles (mandatory conventions)

VariableRoleScrooge & Marley value
--bb-1Primary text#0D0D0D — soot black
--bb-2Primary accent, headers#4A4A4A — iron grey
--bb-3Danger, overdue, emphasis#8B0000 — dried-ink red
--bb-4Highlight, callout#8B7536 — cold brass
--bb-5Secondary accent#2F2F2F — charcoal
--bb-6Muted accent, labels#6B6B5E — tarnished pewter
--bb-7Borders, subtle backgrounds#9C9680 — aged ledger tan
--bb-8Page background, light surfaces#E8E0D0 — parchment
WCAG AA contract WCAG AA requires 4.5:1 contrast for body text and 3:1 for large text. When replacing palette values for a new brand, verify --bb-1 against --bb-8 and --bb-2 against --bb-8 before deploying. Many brand primaries fail at body text size.

4. Site structure and routes

Public routes

Admin routes (protected)

5. Content systems

Blog system

The blog system uses Neon PostgreSQL for post storage, Tiptap for authoring, and Vercel Blob for image storage. Posts are database-driven; the admin editor produces clean HTML stored in the content column.

Key capabilities
Adding a D3 visualisation
  1. Create lib/viz/[name].ts exporting default (el: HTMLElement) => void
  2. Add an entry to lib/viz/registry.ts mapping the name to a lazy import
  3. Insert a data-viz="[name]" placeholder via the editor toolbar

Tools directory

Tools are served from two sources merged at render time. Artifact tools live as HTML files in public/artifacts/ — filesystem is the source of truth, no database entry needed. Link tools are database-driven, managed via the admin UI.

Two tool types
TypeSourceBehaviourHow to add
artifact Filesystem (public/artifacts/) Card links to /tools/[slug], renders in full-viewport iframe Drop an HTML file with title, description, keywords meta tags. Push to main.
link Neon database Card opens URL in new tab Admin UI at /admin/dashboard/tools

Dev docs browser

All HTML files in public/dev/ are automatically surfaced on /dev. No database, no sync required. The lib/html-meta.ts utility (scanHtmlDir()) reads <title>, <meta name="description">, and <meta name="keywords"> tags from every file and returns them as HtmlDocMeta[].

All three meta tags are required A doc without all three tags does not appear in the browser with correct title or searchable keywords. A doc that appears in the filesystem but cannot be found by search does not exist to the reader. Title, description, and keywords are structural requirements, not formatting suggestions.

Substack importer

The Substack import system ingests Substack export ZIPs and surfaces articles under /substack/[section]/[slug]. Articles are stored in Neon with attribution preserved.

Import workflow
  1. Export from Substack (Settings → Exports → Create new export)
  2. Create a section in admin dashboard (title, slug, Substack URL, description)
  3. Upload the ZIP to that section — parser reads posts.csv + HTML files
  4. Articles upserted by slug — re-import is safe, updates existing records

6. Database schema

Four tables in Neon PostgreSQL. All have row-level security enabled. Public read policies are narrowly scoped — blog posts require published = true.

-- Tools
CREATE TABLE IF NOT EXISTS tools (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  slug TEXT UNIQUE NOT NULL,
  description TEXT,
  tool_type TEXT DEFAULT 'link',       -- 'link' | 'artifact'
  claude_url TEXT,                      -- external URL (link tools) or fallback
  chatgpt_url TEXT,                     -- optional ChatGPT URL
  artifact_id TEXT,                     -- Claude artifact UUID
  artifact_embed_code TEXT,             -- raw iframe embed (overrides artifact_id)
  tags TEXT[],                          -- category tags
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE tools ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_read_tools" ON tools FOR SELECT USING (true);
CREATE POLICY "service_role_tools" ON tools FOR ALL USING (true) WITH CHECK (true);

-- Blog posts
CREATE TABLE IF NOT EXISTS blog_posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  subtitle TEXT,
  slug TEXT NOT NULL UNIQUE,
  byline TEXT,
  cover_image TEXT,
  content TEXT NOT NULL,               -- clean HTML from Tiptap
  excerpt TEXT,                        -- auto-generated, first 200 chars
  published BOOLEAN DEFAULT false,
  published_at TIMESTAMPTZ,
  tags TEXT[] DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE blog_posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_read_published_posts" ON blog_posts
  FOR SELECT USING (published = true);
CREATE POLICY "service_role_posts" ON blog_posts
  FOR ALL USING (true) WITH CHECK (true);

-- Substack sections
CREATE TABLE IF NOT EXISTS substack_sections (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  description TEXT,
  substack_url TEXT NOT NULL,
  article_count INTEGER DEFAULT 0,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE substack_sections ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_read_sections" ON substack_sections FOR SELECT USING (true);
CREATE POLICY "service_role_sections" ON substack_sections
  FOR ALL USING (true) WITH CHECK (true);

-- Substack articles
CREATE TABLE IF NOT EXISTS substack_articles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  section_id UUID NOT NULL REFERENCES substack_sections(id) ON DELETE CASCADE,
  slug TEXT NOT NULL,
  title TEXT NOT NULL,
  subtitle TEXT,
  excerpt TEXT,
  content TEXT,
  original_url TEXT,
  published_at TIMESTAMPTZ,
  display_date TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(section_id, slug)
);
ALTER TABLE substack_articles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_read_articles" ON substack_articles FOR SELECT USING (true);
CREATE POLICY "service_role_articles" ON substack_articles
  FOR ALL USING (true) WITH CHECK (true);

Pending migrations (safe to re-run)

-- Run these in Neon SQL Editor if not already applied
ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS byline TEXT;
ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS tags TEXT[] DEFAULT '{}';
ALTER TABLE blog_posts ADD COLUMN IF NOT EXISTS cover_image TEXT;

7. Admin system

The admin dashboard is protected by middleware.ts, which redirects all /admin/dashboard/* routes to /admin/login if no valid admin_session cookie is present. Authentication is password-only — the password is set via the ADMIN_PASSWORD environment variable.

Session mechanics

Admin API routes

RouteMethodsPurpose
/api/admin/loginPOSTValidate password, set session cookie
/api/admin/blogGET, POSTList all posts / create post
/api/admin/blog/[id]GET, PUT, DELETESingle post operations
/api/admin/blog/import-substackPOSTSubstack ZIP → blog drafts
/api/admin/blog/import-jsonPOSTBlog export ZIP → blog drafts
/api/admin/blog/exportGETExport posts as ZIP (tag filter optional)
/api/admin/uploadPOSTImage upload to Vercel Blob
/api/admin/toolsGET, POSTList / create link tools
/api/admin/tools/[id]PUT, DELETEUpdate / delete link tool
/api/admin/dev/syncPOSTScan public/dev/, return doc metadata
/api/admin/substack/sectionsGET, POSTList / create Substack sections
/api/admin/substack/sections/[id]PUT, DELETEUpdate / delete section
/api/admin/substack/uploadPOSTParse ZIP, upsert articles

8. Environment variables

VariableRequiredPurpose
DATABASE_URL Yes Neon PostgreSQL connection string. From Vercel marketplace or Neon dashboard.
ADMIN_PASSWORD Yes Password for /admin/login. Set a strong value in production. Do not commit.
NEXT_PUBLIC_SITE_URL Yes Full domain, e.g. https://scroogeandmarley.com. Used in sitemap generation.
BLOB_READ_WRITE_TOKEN Yes Vercel Blob token. Required for image uploads in the blog editor.
NEXT_PUBLIC_GA_ID No Google Analytics measurement ID (e.g. G-XXXXXXXXXX). Optional.
NEXT_PUBLIC_ANTHROPIC_API_KEY No Only if embedding an AI assistant directly in the site.

9. Persistent layout components

Header

Sticky, z-50, backdrop-blur. Logo (theme-aware SVG or text), five-item nav, social icon buttons, dark/light mode toggle. Mobile hamburger menu at the lg breakpoint. Do not add a sixth nav item without a deliberate information architecture decision — five is not arbitrary.

Footer

Four-column grid: firm info (name, address, contact), platform links, connect/social links, legal links. Bottom bar with copyright. Column headings and link text are brand-specific copy — the only footer content that changes between instances.

SEO infrastructure

10. Five proposed additions

These are structural proposals, not implementation tickets. Each one addresses a real gap in the current template. They are ordered by the ratio of effort to usefulness, not by complexity.

1. Brand registry — single-file multi-instance configuration
Planned

Currently, switching brand instances requires manual edits to three files (lib/theme.ts, public/theme.json, app/globals.css) plus the home page, legal pages, and CLAUDE.md. There is no single file that declares "this is the Scrooge & Marley instance." A developer making a new instance must know which files to change.

Add a config/brand.ts file that is the single source of truth for the active brand: palette, copy, address, legal entity, home page section content. The three theme files and the legal pages are generated from it, not maintained separately. A new brand instance is one file plus assets.

A developer could drop in a new brand config, run a generation script, and have a fully configured instance in minutes. The multi-brand demonstration becomes something a user can try themselves, not just read about.

2. Contact form with Resend integration
Planned

Every CTA on the current site routes to a mailto: link. This means a visitor must have a configured email client. On mobile this works; in many corporate environments it does not. There is also no record of enquiries — they land in an inbox and may be lost.

Add a /contact route (currently a placeholder) with a form that POSTs to /api/contact. The API route validates the fields and sends via Resend (one environment variable, generous free tier). Store a copy of each submission in a new enquiries table in Neon. Surface them in the admin dashboard.

The site becomes genuinely functional as a business template, not just a demonstration. Each brand instance gets a working enquiry pipeline. The admin can see all submissions without checking email.

3. Brand instance switcher in the admin dashboard
Planned

The multi-brand story is the template's primary selling point, but it is invisible to someone looking at a single deployed instance. To see the contrast between Scrooge & Marley and Au Bonheur des Dames, you must visit two different URLs — or read about it in a README.

Add a brand switcher to the admin dashboard (hidden from public visitors) that live-previews any configured brand instance by swapping the CSS variables via a data-brand attribute on the root element. No page reload. The switcher reads all brand configs from the proposed registry and renders a dropdown.

The demo becomes interactive. A developer evaluating the template can experience the full range of brand personalities in a single session, on a single deployment. This is the clearest possible argument for the theming system's real flexibility.

4. Structured projects / portfolio section
Planned

/projects is currently a placeholder. The tools directory serves individual interactive tools, and the blog serves written content, but there is no structured way to present a body of work — a case study, a client engagement record, a research project — as a coherent unit with multiple components.

Add a projects table in Neon with title, slug, summary, status, tags, and a content field (same HTML-from-Tiptap pattern as blog posts). A project can reference multiple blog posts, tools, and external links. The public /projects page renders as a card grid; /projects/[slug] renders the full project with linked artefacts.

For an individual or consultancy using the template, this closes the gap between "I have blog posts" and "I have a portfolio." For the multi-brand demonstration, it gives each fictional firm a place to show completed engagements.

5. Indiana — automated dev doc generation from CLAUDE.md
Planned

Every doc in public/dev/ is hand-authored. The CLAUDE.md file contains authoritative, structured information about the codebase — site structure, schema, routes, environment variables — that duplicates what the dev docs cover. When CLAUDE.md changes, the dev docs become stale. There is no automated connection between the two.

Indiana is a lightweight script (scripts/indiana.ts) that reads CLAUDE.md and public/theme.json, extracts structured sections, and generates or regenerates specific dev doc HTML files in public/dev/. It does not replace hand-authored docs — it generates the reference docs (schema, routes, environment variables) that are purely derived from source truth and should not require manual maintenance.

The dev docs stay current automatically. A change to the database schema in CLAUDE.md is reflected in the dev docs on the next build. The hand-authored explanation and how-to docs remain under human control; the reference docs are generated. This is the documentation-as-code pattern applied to the template itself.