lovable
Lovable to Next.js: ship Lovable as a versioned package
A field report on going Lovable to Next.js without migrating: build Lovable into one immutable v* artifact, keep a single React instance, and ship to production.

A field report from the ContextCapture build — co-authored by the AppHandoff and InspiredByFrustration teams.
Lovable does not generate Next.js, so we stopped trying to make it. Instead we ship Lovable into Next.js.
Going from Lovable to Next.js in production does not have to mean porting the code at all. We treat the Lovable repo (Vite + React + Tailwind + shadcn) as a pure UI factory and build it into one immutable, v*-tagged npm-style artifact. The Next.js parent imports that exact tag and owns all I/O. The two repos never share a database — they share a contract.
This post is about the seam between the two: the conventions, the small amount of code, and the tricks that make "build it in Lovable, ship it in Next" actually pleasant. If you've ever grafted 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.
Two ways to take Lovable to Next.js. This is the informational "keep both repos, consume Lovable as a package" approach. If you instead want to extract the prototype, fix the architecture, wire backends, and own a single Next.js codebase with no vendor lock-in, that's our commercial Lovable to Next.js migration service — the opposite strategy, deliberately. Pick the one that matches your intent; the rest of this post assumes you're keeping the two repos.
The setup: two repos, one product
Two repos, one product. One produces UI; one runs in production.
| Repo | Owner | Stack | What it ships |
|---|---|---|---|
context-capture-v2 (Lovable) | Design / FE | Vite + React 18 + Tailwind + shadcn | Marketing site components, embeddable widget, public types |
context-capture (Parent) | Platform / Backend | Next.js + Supabase + Fly + GitHub OAuth | API 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 is a pure UI factory. Everything stateful is a callback prop the parent fills in. The designer never has to open the Next.js repo, and the platform team never has to touch a Tailwind class.
Three rules for shipping a Lovable design system to production
The whole pattern rests on three rules. Break any one and the seam starts leaking — duplicate React, drifting contracts, or a design team blocked on backend deploys.
Rule 1 — treat the Lovable export code 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 export code 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/contract→ zero React, zero DOM — just types,SCHEMA_VERSION, and anassertCompatible()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, and the parent passes implementations down as props:
// 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 — host the contract as a live /handover route
This is the trick worth stealing. Both repos must 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 fromdist-pkg/build-info.jsonat 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.tsxin the same commit.
The handover page is the contract. Because it's a real React page — not a markdown file — 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 Lovable to Next.js pipeline, end to end
The pipeline is the part that turns three rules into something repeatable. A designer pushes in Lovable; a release script builds and tags an immutable artifact; the Next.js parent pins that exact tag and deploys to Fly.

The Lovable repo (Vite + React) builds an immutable v*-tagged artifact; the Next.js parent pins the exact tag, imports @contextcapture/widget, and deploys to Fly.
The release script (bun run release) does five things in order:
- Verifies a clean git tree.
- Runs the public-API snapshot test — a single file that records every exported symbol and its type.
- Detects the semver bump automatically from the snapshot diff:
SCHEMA_VERSIONchanged → major- exports removed or renamed → major
- exports added only → minor
- no surface change → patch
- Bumps
package.json, prepends aCHANGELOG.mdentry, buildsdist-pkg/. - 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 the one CI check we lean on here: it 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." That discipline — an immutable tag plus a check that refuses anything but a tag — is core to how we approach Lovable development on real production products.
What the immutable 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 — no guessing about which Tailwind tweak shipped when.
A reference ASCII view of the seam
For anyone mapping this onto their own repos, here's the seam as a single diagram — what lives where, and what crosses the boundary:
┌────────────────────────────┐ ┌────────────────────────────┐
│ 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 boundary is the tag and the /contract entrypoint. Nothing else crosses it.
What we'd recommend if you're copying this pattern
- 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.mdand enforce it in PR review. Drift here is what kills the setup. - Make React external in the package build. Non-negotiable.
- Split your contract from your runtime. A
/contractentrypoint with zero React is what lets server code participate safely. - Host your handover as a real page in the producer app, not a markdown file. It will be wrong within a week otherwise.
- Automate semver from a snapshot. Humans guess wrong about what's breaking. Diff the public surface and let the script decide.
- Pin exact tags from the consumer, never branches. And put a CI check on it.
Frequently asked questions
Can Lovable generate NextJS?
No. Lovable outputs a Vite + React + Tailwind + shadcn app, not a Next.js project. The way we use it, you don't ask Lovable for Next.js at all — you let it produce idiomatic React UI, then build that repo into a versioned package the Next.js parent imports. The Next.js app is owned and written by the platform team; Lovable only ships components, sections, and types.
Is Lovable React or Next?
Lovable is React, not Next. It scaffolds a Vite-based React 18 app with Tailwind and shadcn. That's actually what makes the artifact portable: plain React with externalized dependencies drops cleanly into a Next.js host that already runs its own React instance, so you get one React tree instead of two.
Can you convert a Lovable project to Next.js?
You can, and that full extract-and-own conversion is exactly what our Lovable to Next.js migration service does. But the pattern in this post argues the opposite: instead of porting the Lovable codebase into Next.js, you keep it as a standalone Vite repo and consume its build output — a single immutable v*-tagged artifact with a React-external runtime and an SSR-safe /contract entrypoint. Each repo stays in its native stack and moves at its own speed.
Footnotes
- Lovable — the 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
/handoverroute 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

