What the template contains, how each system works, and five proposed additions. For developers onboarding to the codebase or planning extensions.
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 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.
| 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.
| Layer | Technology | Purpose |
|---|---|---|
| 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/ |
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.
Exports a typed theme constant containing the brand name, tagline, address, contact, domain, and the eight colour values (bb1–bb8). This is the canonical source. If it conflicts with the other two files, this one wins.
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.
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.
| Variable | Role | Scrooge & Marley value |
|---|---|---|
--bb-1 | Primary text | #0D0D0D — soot black |
--bb-2 | Primary accent, headers | #4A4A4A — iron grey |
--bb-3 | Danger, overdue, emphasis | #8B0000 — dried-ink red |
--bb-4 | Highlight, callout | #8B7536 — cold brass |
--bb-5 | Secondary accent | #2F2F2F — charcoal |
--bb-6 | Muted accent, labels | #6B6B5E — tarnished pewter |
--bb-7 | Borders, subtle backgrounds | #9C9680 — aged ledger tan |
--bb-8 | Page background, light surfaces | #E8E0D0 — parchment |
--bb-1 against --bb-8 and --bb-2 against --bb-8 before deploying. Many brand primaries fail at body text size.
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.
TEXT[] array — filterable in both admin and public viewspublished_at timestampposts.json + individual HTML files) — enables cross-instance transferBlogVizHydrator and the viz registrylib/viz/[name].ts exporting default (el: HTMLElement) => voidlib/viz/registry.ts mapping the name to a lazy importdata-viz="[name]" placeholder via the editor toolbarTools 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.
| Type | Source | Behaviour | How 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 |
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[].
The Substack import system ingests Substack export ZIPs and surfaces articles under /substack/[section]/[slug]. Articles are stored in Neon with attribution preserved.
posts.csv + HTML filesFour 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);
-- 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;
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.
/api/admin/login — validates against ADMIN_PASSWORD env varadmin_session httpOnly cookie, 7-day expiry/api/admin/* routes check isAdmin() from lib/admin-auth.ts before proceeding| Route | Methods | Purpose |
|---|---|---|
/api/admin/login | POST | Validate password, set session cookie |
/api/admin/blog | GET, POST | List all posts / create post |
/api/admin/blog/[id] | GET, PUT, DELETE | Single post operations |
/api/admin/blog/import-substack | POST | Substack ZIP → blog drafts |
/api/admin/blog/import-json | POST | Blog export ZIP → blog drafts |
/api/admin/blog/export | GET | Export posts as ZIP (tag filter optional) |
/api/admin/upload | POST | Image upload to Vercel Blob |
/api/admin/tools | GET, POST | List / create link tools |
/api/admin/tools/[id] | PUT, DELETE | Update / delete link tool |
/api/admin/dev/sync | POST | Scan public/dev/, return doc metadata |
/api/admin/substack/sections | GET, POST | List / create Substack sections |
/api/admin/substack/sections/[id] | PUT, DELETE | Update / delete section |
/api/admin/substack/upload | POST | Parse ZIP, upsert articles |
| Variable | Required | Purpose |
|---|---|---|
| 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. |
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.
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.
app/sitemap.ts — dynamic sitemap including all /blog/*, /tools/*, /substack/* routes from Neon. Falls back to static-only if DB is not configured.app/robots.ts — allows all crawlers, blocks /admin/ and /api/, points to /sitemap.xml.og:image and twitter:card meta tags.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.
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.
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.
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.
/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.
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.