An end-to-end walkthrough for running snippets that live outside your build - wherever they come from and whoever writes them.
Architecture#
Only three things have to be true. Everything else is your application's existing shape:
1. STOREthe snippet source lives somewhere you already control(database row, CMS field, file in your repo)││ 2. SERVE│ your app hands it to the client│ as a plain string▼<LiveProvider code={source}>││ 3. RUN│ compiled in the browser▼a live React component
The names below are illustrative
This guide uses a database, an admin UI, and a /api/apps/[id] route because
a concrete example is easier to follow than an abstract one, but next-live
has no opinion about any of them. It never fetches anything itself. If your
snippets are Markdown frontmatter, rows in Postgres, or a constant in the
repo, keep whatever you already have: the only requirement is that code is
a string by the time it reaches <LiveProvider> in the browser.
- 1
Decide your SDK surface first
Pick stable namespaces before you write any code. These become a public contract with your authors, renaming
@app/storelater breaks every stored snippet that imported it.The contract@app/store application state (hooks + actions)@app/ui buttons, cards, layout primitives@app/format dates, currency, numbers@app/data fetch helpersKeep the surface small and boring. Every export is something you have to keep working.
- 2
Build the registry from small files
One file per domain, composed in an
index.ts. This beats a literal inlined onLiveProvider: the groups are testable, andcreateRegistrywarns in development when two of them claim the same key.lib/live-sdk/index.tsimport { createRegistry } from 'next-live';import { formatModules } from './format-modules';import { storeModules } from './store-modules';import { uiModules } from './ui-modules';import { vendorModules } from './vendor';export const liveModules = createRegistry(vendorModules,uiModules,formatModules,storeModules,);Every entry is defineLoader, so a snippet that never imports @app/ui does not pull it into the page.
- 3
Store snippets as strings
A snippet is TSX source text, not a file path. You can author
.tsxfiles in your repo and read them server-side, butLiveProvideralways receives a string.Whatever your storage looks like, the snippet is text{id: 'dashboard-widget',name: 'Revenue widget',source: "import { Card } from '@app/ui';\n\nexport default function App() { … }",}A database row, a CMS entry, or a .tsx file you read at build time, next-live only sees the string. - 4
Serve it behind whatever auth you already have
If the source is already in your bundle or read at build time, skip this step entirely; there is nothing to fetch. If you serve it over HTTP, the route is an ordinary authenticated endpoint:
app/api/apps/[id]/route.tsexport async function GET(request: Request, { params }) {const session = await auth(request);if (!session) {return Response.json({ error: 'unauthorized' }, { status: 401 });}const app = await db.apps.find(params.id);if (!app) return Response.json({ error: 'not found' }, { status: 404 });return Response.json({ id: app.id, source: app.source });}Guard the write path even harder
Whoever can POST a snippet can run JavaScript on your site, as your users, with their cookies. Read access controls who sees an app; write access controls who owns your frontend. See Security. - 5
Render it, and persist edits
onCodeChangefires only for edits made inside the runner, never when thecodeprop changes from outside. That is what makes it safe to save from: it cannot echo your own writes back at you in a loop.app/apps/[id]/AppRunner.tsx'use client';import { LiveProvider, LivePreview, LiveError } from 'next-live';import { LiveEditor } from 'next-live/editor';import { liveModules } from '@/lib/live-sdk';export function AppRunner({ id, source, user, canEdit }) {return (<LiveProvidercode={source}modules={liveModules}props={{ user }}filePath={`app-${id}.tsx`}onCodeChange={canEdit ? (next) => saveDraft(id, next) : undefined}fallback={<Skeleton className="h-80 w-full" />}>{canEdit && <LiveEditor />}<LivePreview /><LiveError /></LiveProvider>);}filePath gives each app a stable identity, so stack traces and DevTools name the right thing. - 6
Add CSP and CI validation
Scope
unsafe-evalto the runner routes only, and runvalidateSnippetsover every stored app in CI so an SDK rename fails your build instead of your users' pages.A CI gateimport { validateSnippets } from 'next-live/server';const failures = validateSnippets(await db.apps.all(), {modules: LIVE_MODULE_KEYS,forbidRemoteImports: true,});if (failures.length > 0) {console.error(failures);process.exit(1);}Full detail in Security and Validating in CI.
See it running#
The Apps shell demo in this repo implements exactly this pattern:
sidebar tabs, API-fetched snippets, shadcn components through @app/ui, and a
store shared with the host page through @app/store.
Code is always a string
Whether it comes from a database, an API, or a template literal in your repo,
code={source} is the only input next-live accepts. There is
no file-path mode to look for.
