Skip to content
back to journal

lovable

Shipping a Lovable Design System Into a Next.js Monorepo

A production guide for porting Lovable-built app slices into a Next.js monorepo with SSR safety, schema contracts, and SEO guardrails.

Ralph Duin · 8 min read
XLI
Shipping a Lovable Design System Into a Next.js Monorepo

TL;DR

We use Lovable (Vite + React + Tailwind + shadcn) as the design and UI producer for a product whose production runtime is a Next.js app owned by a separate team. The two repos never share a database. They share one immutable npm-style artifact versioned by a v* git tag.

This post is about the seam between the two — the tricks, the conventions, and the small amount of code that makes "build it in Lovable, ship it in Next" actually pleasant.

If you've ever tried to graft a generated UI into a real production app and ended up with three copies of React, a broken SSR build, and a designer who can't preview their changes — this is for you.


The setup

Two repos, one product:

RepoOwnerStackWhat it ships
context-capture-v2 (Lovable)Design / FEVite + React 18 + Tailwind + shadcnMarketing site components, embeddable widget, public types
context-capture (Parent)Platform / BackendNext.js + Supabase + Fly + GitHub OAuthAPI routes, auth, persistence, deploy, CI/CD

The interesting constraint: the Lovable repo has no backend. No DB, no API routes, no env-bound secrets at runtime. It's a pure UI factory. Everything stateful is a callback prop the parent fills in.

┌────────────────────────────┐         ┌────────────────────────────┐
│  Lovable repo (Vite)       │   tag   │  Next.js parent repo       │
│                            │ ──────▶ │                            │
│  src/site/      (sections) │  v0.2.3 │  app/(marketing)/page.tsx  │
│  src/widget/    (runtime)  │         │    └─ <ContextCaptureHome/>│
│  src/widget/contract.ts    │         │  app/api/issues/route.ts   │
│                            │         │    └─ import { Schema }    │
│  bun run release ──▶ dist-pkg/       │         from "…/contract"  │
└────────────────────────────┘         └────────────────────────────┘
        designers iterate                   backend wires callbacks
        in `lovable.dev` preview            and owns the database

The whole point: the designer never has to open the Next.js repo, and the platform team never has to touch a Tailwind class.


The three rules that made this work

Rule 1 — Treat the Lovable repo as a package, not a website

The instinct is to "deploy the Lovable preview" and iframe it, or to copy/paste components into Next on each release. Both are dead ends.

Instead we build the Lovable repo as a real npm-style artifact with three entrypoints:

// package.json — exports map in the Lovable repo
{
  "exports": {
    ".":           { /* widget runtime (React)       */ },
    "./site":      { /* marketing sections (React)   */ },
    "./contract":  { /* SSR-safe types + schema only */ },
    "./widget.css": "./dist-pkg/widget.css"
  }
}
  • @contextcapture/widget → the runtime widget, mounted in client components.
  • @contextcapture/widget/site → the marketing page sections (<Hero>, <Pricing>, …), composable inside Next pages.
  • @contextcapture/widget/contractzero React, zero DOM — just types, SCHEMA_VERSION, and an assertCompatible() helper. Safe to import from a Next Route Handler or an edge worker.

That last split is the unlock. Server code can validate payloads against the same types the widget produces, without dragging React into the server bundle:

// app/api/issues/route.ts  (Next.js parent)
import {
  SCHEMA_VERSION,
  assertCompatible,
  type IssuePayload,
} from "@contextcapture/widget/contract";

assertCompatible(SCHEMA_VERSION); // boot-time guard

export async function POST(req: Request) {
  const body = (await req.json()) as IssuePayload;
  // …validate, persist, fan out
}

In vite.pkg.config.ts we mark React as external so the parent's single React instance is reused — this is what kills the "two copies of React → broken hooks" class of bugs:

// vite.pkg.config.ts (excerpt)
rollupOptions: {
  external: [
    "react",
    "react-dom",
    "react/jsx-runtime",
    "react/jsx-dev-runtime",
    "html2canvas", // dynamic-imported, host can dedupe/lazy-fetch
  ],
}

Rule 2 — All product I/O is a callback prop

The Lovable repo never knows the parent's URLs. It never fetches /api/.... The widget exposes a typed surface:

// public widget contract (simplified)
export interface WidgetConfig {
  workspace: string;
  onSubmitIssue?: (payload: IssuePayload) => Promise<void>;
  onSaveFlow?:    (payload: FlowSavePayload) => Promise<void>;
  onUploadAsset?: (file: File, kind: AssetKind) => Promise<{ url: string }>;
  onSignIn?:      (creds: SignInCredentials) => Promise<void>;
  getAuthToken?:  () => Promise<string | null>;
  onTelemetry?:   (event: TelemetryEvent) => void;
}

In the parent, mounting it looks like this:

// app/(app)/layout.tsx  (Next.js parent — client component)
"use client";
import { Widget } from "@contextcapture/widget";
import "@contextcapture/widget/widget.css";
import { submitIssue, saveFlow, uploadAsset } from "@/lib/cc-adapters";

export function ContextCaptureMount() {
  return (
    <Widget
      workspace="acme"
      onSubmitIssue={submitIssue}
      onSaveFlow={saveFlow}
      onUploadAsset={uploadAsset}
      getAuthToken={() => fetch("/api/cc/token").then(r => r.text())}
    />
  );
}

The benefit is brutal in its simplicity: the Lovable repo can be reviewed, tested, and previewed completely standalone, with mock adapters. The parent team owns auth, persistence, and rate-limiting, and never has to fork the widget to change those.

Rule 3 — A "public pages" trick: the /handover route

This is the trick worth stealing.

Both repos need to agree on a lot of contract: prop names, event shapes, schema version, hotkeys, CSS classes that are allowed to be styled-around, build outputs. Documenting that in a README.md is a graveyard — nobody reads it, and it drifts the moment a prop is renamed.

So inside the Lovable repo we keep a live, rendered handover page at /handover, served from the same Vite app the designer is already using:

// src/App.tsx (Lovable)
<Route path="/handover" element={<HandoverLayout />}>
  <Route index element={<Handover />} />
  <Route path="widget-demo" element={<WidgetDemo />} />
</Route>

/handover contains:

  • The integration snippet, copy-pasteable, typed against the actual barrel so a rename breaks the page.
  • A live <WidgetDemo> route that mounts every widget surface in isolation — modal open, sign-in state, flow recorder mid-step. The parent QA team uses this as their visual reference.
  • The current SCHEMA_VERSION, build hash, and tag, pulled from dist-pkg/build-info.json at build time.

Our rule, encoded in AGENTS.md and enforced in PR review:

Every public prop, callback, type, schema, hotkey, CSS, or build-output change must update src/pages/Handover.tsx in the same commit.

The handover page is the contract. The fact that it's a real React page — not a markdown file — means broken examples fail the build, and the design team can preview it the same way they preview the marketing site.

This is the "public page for code" trick: use the framework's own routing to host the contract, so the contract has the same review and preview surface as the product.


The pipeline end-to-end

Lovable → Next.js production pipeline: the Lovable repo builds an immutable v* tagged artifact (dist-pkg/); the Next.js parent imports it as a pinned submodule with parent-owned callbacks for I/O.

┌──────────────┐   git push   ┌──────────────┐  bun run release  ┌──────────────┐
│  Designer    │ ───────────▶ │   Lovable    │ ────────────────▶ │   v0.2.3     │
│  in lovable  │              │   preview    │  builds dist-pkg/ │   git tag    │
│  .dev IDE    │              │  (Vite HMR)  │  + build-info.json│  (immutable) │
└──────────────┘              └──────────────┘                   └──────┬───────┘
                                                                        │
                                  ┌─────────────────────────────────────┘
                                  ▼ parent updates submodule SHA → tag
                          ┌──────────────┐    deploy     ┌──────────────┐
                          │ Next.js repo │ ────────────▶ │     Fly      │
                          │  imports     │   pinned to   │  production  │
                          │  @cc/widget  │   exact tag   │              │
                          └──────────────┘               └──────────────┘

The release script (bun run release) does five things in order:

  1. Verifies a clean git tree.
  2. Runs the public-API snapshot test — a single file that records every exported symbol and its type.
  3. Detects the semver bump automatically from the snapshot diff:
    • SCHEMA_VERSION changed → major
    • exports removed or renamed → major
    • exports added only → minor
    • no surface change → patch
  4. Bumps package.json, prepends a CHANGELOG.md entry, builds dist-pkg/.
  5. Commits the artifact and tags it vX.Y.Z.
// scripts/release.mjs (excerpt — the bump detector)
function detectBump(prevTag) {
  if (explicitBump) return explicitBump;
  if (!prevTag) return "minor";

  const diffSnap  = sh(`git diff ${prevTag}..HEAD -- ${snapshotPath}`).trim();
  const diffTypes = sh(`git diff ${prevTag}..HEAD -- ${typesPath}`).trim();

  if (schemaVersionChanged(diffTypes))           return "major";
  if (hasRemovalsOrRenames(diffSnap))            return "major";
  if (diffSnap.length > 0)                       return "minor";
  return "patch";
}

The parent repo never pins a branch SHA — only an exact v* tag SHA. That's enforced by a CI check that fails if the submodule pointer resolves to a non-tag commit. It saved us twice already from "works on my machine because the branch moved under me."


What the artifact actually contains

dist-pkg/
├── index.mjs              # widget runtime (ESM)
├── index.cjs              # widget runtime (CJS)
├── contract.mjs / .cjs    # SSR-safe types + SCHEMA_VERSION
├── site.mjs / .cjs        # marketing sections
├── widget.css             # one stylesheet, scoped
├── build-info.json        # version, commit, schemaVersion, builtAt
├── INTEGRATION.md         # auto-generated snippets, travels with the tag
└── widget/*.d.ts          # type declarations

build-info.json is the receipt:

{
  "name": "@contextcapture/widget",
  "version": "0.2.3",
  "sourceCommit": "2879010",
  "schemaVersion": 1,
  "builtAt": "2026-05-25T14:34:26.205Z"
}

The parent imports it at runtime to surface "Widget v0.2.3" in its own admin footer. When a bug report comes in, we instantly know which artifact is running.


What we'd recommend if you're copying this pattern

  1. Decide on the seam before you write the first component. Ours is "Lovable owns UI and types, parent owns I/O and persistence." Write that down in an AGENTS.md and enforce it in PR review. Drift here is what kills the setup.
  2. Make React external in the package build. Non-negotiable.
  3. Split your contract from your runtime. A /contract entrypoint with zero React is what lets server code participate safely.
  4. Host your handover as a real page in the producer app, not a markdown file. It will be wrong within a week otherwise.
  5. Automate semver from a snapshot. Humans guess wrong about what's breaking. Diff the public surface and let the script decide.
  6. Pin exact tags from the consumer, never branches. And put a CI check on it.

Footnotes

  • Lovable — visual AI builder we use for the producer repo. Outputs idiomatic Vite + React + Tailwind + shadcn, which is exactly what makes the artifact portable.
  • AppHandoff — the contract-and-handoff process described above; the /handover route is its physical manifestation.
  • InspiredByFrustration — what got us building this seam in the first place: tired of designers blocked on backend deploys, and backend engineers blocked on Tailwind tokens.

If you're shipping a product with a design-led front of house and an engineering-led back of house, this pattern lets each side move at its own speed without either repo becoming a hostage of the other.

— The ContextCapture team


Related from the Lovable cluster

▢ end of post
XLinkedIn