This page explains the idea behind modules. If you only remember one thing: live snippets cannot import anything you did not register. You choose what they can reach, and you give each thing a name.
The problem in plain terms#
In normal Next.js code you write:
import { Button } from '@/components/ui/button';
Your bundler finds that file at build time and ships it to the browser. Done.
A live snippet is different. It is a string stored somewhere (a database, a CMS, a textarea). It was not part of the build. When the browser runs it, there is no bundler sitting there resolving @/ paths or fetching npm packages.
So this inside a snippet:
import _ from 'lodash';
does not mean "go install lodash." It means "look up lodash in the registry I was given." If you never registered lodash, the snippet stops with an error that names the missing specifier.
That registry is the modules prop on <LiveProvider>.
A simple mental model#
Think of modules as a phone book for your snippets:
| Snippet writes | Phone book entry | What actually loads |
|---|---|---|
import { Button } from '@app/ui' | '@app/ui' | Your real UI module |
import { useCart } from '@app/store' | '@app/store' | Your real store |
import _ from 'lodash' | (missing) | Error |
The snippet only knows the label ('@app/ui'). You decide which real code that label points to.
Walkthrough: from string to rendered component#
Here is what happens when a user runs a snippet.
1. You pass the source string and a registry
'use client';import { LiveProvider, LivePreview, defineLoader } from 'next-live';<LiveProvidercode={source}modules={{'@app/format': defineLoader(() => import('@/lib/format')),}}><LivePreview /></LiveProvider>
2. The snippet uses a normal import
import { formatMoney } from '@app/format';export default function Price() {return <p>{formatMoney(99)}</p>;}
3. next-live compiles the import
It turns import { formatMoney } from '@app/format' into a lookup: "give me whatever is registered under '@app/format'."
4. The loader runs
defineLoader(() => import('@/lib/format')) is a lazy dynamic import. It only runs when a snippet actually imports '@app/format'. Your bundler can code-split it.
5. The export is handed to the snippet
formatMoney comes from your real @/lib/format file. The snippet renders with it.
Try it live:
Preview
Source sent to LiveProvider
The key is just a string you choose#
The left side of modules does not have to be a real npm package or a real file path. It is whatever you want snippet authors to type.
All of these are valid. They could even point at the same file:
modules={{'@app/store': defineLoader(() => import('@/lib/store')),'@/store': defineLoader(() => import('@/lib/store')),'my-store': defineLoader(() => import('@/lib/store')),}}
Pick names that read like a small SDK surface, not a dump of your folder tree. This site uses @app/ui, @app/store, and @app/format so docs and demos look like deliberate imports rather than internal paths.
On this playground, files in lib/live-sdk/modules/ are exposed automatically as @app/{filename}. That is a convention we chose. You could name them anything.
Registration is not security
The registry controls what snippets can conveniently import. It does not sandbox the browser. Snippets still run in your page with access to fetch, localStorage, and cookies. See Security.
What fails, and what the error looks like#
If a snippet imports something you forgot to register:
import { Chart } from '@app/charts'; // never registered
next-live stops with a message naming '@app/charts' and listing what is registered. No silent undefined, no blank screen without explanation.
Unused imports are stripped during compile (same as TypeScript would). A typo in an import the snippet never calls will not error; it simply disappears.
scope vs modules#
An older pattern injects everything as globals through scope:
<LiveProvidercode={code}scope={{ useState, Button, formatMoney }}/>
export default function App() {const [n, setN] = useState(0);return <Button>{formatMoney(n)}</Button>;}
Every helper had to be injected by name. There was no import. As the list grew, the scope object became hard to maintain.
next-live lets snippets write real imports:
<LiveProvidercode={code}modules={{'@app/ui': defineLoader(() => import('@/components/ui')),'@app/format': defineLoader(() => import('@/lib/format')),}}/>
import { useState } from 'react';import { Button } from '@app/ui';import { formatMoney } from '@app/format';export default function App() {const [n, setN] = useState(0);return <Button>{formatMoney(n)}</Button>;}
react is built in. Everything else you register explicitly. Snippets read like real files.
You can still use scope for bare identifiers when you need legacy snippets to run unchanged. Prefer modules for anything new.
Loaders vs values#
Two ways to put something in the registry.
Loader (recommended):
'@app/store': defineLoader(() => import('@/lib/store'))
- Loaded only when a snippet imports it
- Code-split by your bundler
- Cheap to register many entries (300 loaders cost almost nothing until used)
Value (direct import):
import * as store from '@/lib/store';modules={{ '@app/store': store }}
- Simpler to read
- Always included in your page bundle for every visitor, even if no snippet uses it
Use loaders for anything non-trivial. Use a plain value only for tiny constants.
Organizing your registry in separate files#
You do not need a giant modules={{ … }} object on <LiveProvider>. Put the full list in a dedicated SDK folder and import one object.
100 registered loaders does not mean 100 network requests. Each entry is a tiny loader function. next-live scans the snippet, finds which specifiers it imports, and only runs those loaders. Snippet A that imports @app/x loads only x. Snippet B that imports @app/z loads only z.
Recommended layout#
index.ts # export liveModules (one import for LiveProvider)ui-modules.ts # manual group: @app/uiformat-modules.ts # manual group: @app/formatstore-modules.ts # manual group: @app/storevendor.ts # third-party / prefix loadersapp-modules-glob.ts # optional: auto-register from ./modules/*.tsmodules/ui.ts # re-exports Button, Card, …format.ts # re-exports formatMoney, …store.ts # re-exports useCart, addItem, …
Option A: manual groups + createRegistry#
Split by domain. Each file holds defineLoader entries:
import { defineLoader } from 'next-live';export const formatModules = {'@app/format': defineLoader(() => import('./modules/format')),'@app/x': defineLoader(() => import('@/lib/x')),'@app/z': defineLoader(() => import('@/lib/z')),};
Merge in one place:
import { createRegistry } from 'next-live';import { vendorModules } from './vendor';import { uiModules } from './ui-modules';import { formatModules } from './format-modules';import { storeModules } from './store-modules';export const liveModules = createRegistry(vendorModules,uiModules,formatModules,storeModules,);
Keep the runner thin:
'use client';import { LiveProvider, LivePreview, LiveError } from 'next-live';import { liveModules } from '@/lib/live-sdk';export function Runner({ source, user }) {return (<LiveProvider code={source} modules={liveModules} props={{ user }}><LivePreview /><LiveError /></LiveProvider>);}
This playground uses Option A for @app/ui, @app/format, and @app/store. See lib/live-sdk/ in the repo.
Option B: registryFromGlob when the list grows#
When you have dozens of SDK modules, hand lists get tedious. Auto-register every file in a folder:
import { registryFromGlob } from 'next-live';export const appModulesFromGlob = registryFromGlob(import.meta.glob('./modules/*.ts'),(path) => {const name = path.split('/').pop()?.replace(/\.tsx?$/, '');return name ? `@app/${name}` : null;},);
Add lib/live-sdk/modules/charts.ts and it becomes @app/charts with no edit to LiveProvider.
import.meta.glob must stay in your file (the bundler resolves it statically). Under webpack, build an equivalent object from require.context instead.
Option C: hybrid#
Use manual groups for special cases (prefix loaders, npm packages) and glob for the rest:
export const liveModules = createRegistry(vendorModules, // '@demo/vendor/' prefix loaderappModulesFromGlob, // every file in ./modules/);
Lazy loading still applies
Registering many modules only adds small loader functions to your page bundle. The heavy code behind each loader is code-split and fetched only when a snippet imports that specifier.
Live demo: UI components through the registry#
This snippet imports shadcn components as @app/ui. Tailwind classes are compiled by the host app, not inside the snippet string. The preview is the point: a styled Card, Badge, and Button loaded through your registry. Click the button to confirm it is a real component (the counter updates below it).
Preview
Source sent to LiveProvider
Host-side, the registration looks like:
// lib/live-sdk/modules/ui.tsexport { Button } from '@/components/ui/button';export { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';// LiveProvider registry'@app/ui': defineLoader(() => import('@/lib/live-sdk/modules/ui'))
Snippet authors get styled components without you exposing every internal path.
Live demo: shared app state#
When you register your real store, the snippet shares one instance with the rest of your app. Not a copy.
Preview
Source sent to LiveProvider
See Sharing libraries for why that matters and when it breaks.
Built in: no registration needed#
These work in every snippet with zero config:
reactreact/jsx-runtimereact/jsx-dev-runtime
They come from your React install, which is why hooks and context work across the snippet boundary.
react-dom is not included. Register it yourself if snippets need createPortal.
Every import form works#
Once '@app/ui' is registered, all of these work:
// default export (or the module itself if there is no default)import ui from '@app/ui';// named exportsimport { Button, Card } from '@app/ui';// namespaceimport * as ui from '@app/ui';// side effects onlyimport '@app/ui';
| Snippet writes | Gets |
|---|---|
import ui from '@app/ui' | default export, or the module itself |
import { Button } from '@app/ui' | the named export |
import * as ui from '@app/ui' | the namespace |
import '@app/ui' | side effects only |
Passing data without registering it#
Not everything needs to be a module. Host objects can go through props:
<LiveProvider code={source} props={{ user, theme }} />
export default function App({ user, theme }) {return <p>{user.name} · {theme.mode}</p>;}
props are passed by reference. Mutations on shared objects (stores, panel handles) update your app immediately. See Sharing libraries.
Use modules for reusable code snippets should import. Use props for one-off instance data from the host page.
Common mistakes#
Import name typo
import { formatCurrency } from '@app/format'; // export is formatMoney
The module loads, but the named export is missing. Fix the import or add an alias in your SDK module.
Registered a different path than your app uses
If your app imports @/stores/cart but the registry loads @/lib/store-copy, you get two store instances. Keep one canonical path. See Sharing libraries.
Expecting npm packages to work automatically
import { format } from 'date-fns'; // only works if YOU register date-fns
Register it explicitly:
'date-fns': defineLoader(() => import('date-fns'))
Raw Tailwind classes in stored source
Arbitrary class strings in a snippet may not produce CSS unless your host Tailwind scan picks them up. Prefer importing registered UI components whose classes are already compiled. See Troubleshooting.
Putting it together#
Minimal host setup (registry lives in lib/live-sdk/, not inline on the provider):
'use client';import { LiveProvider, LivePreview, LiveError } from 'next-live';import { liveModules } from '@/lib/live-sdk';export function Runner({ source, user }) {return (<LiveProvider code={source} modules={liveModules} props={{ user }}><LivePreview /><LiveError /></LiveProvider>);}
Snippet authors then write ordinary React with imports you allow. You control the surface. They get a familiar developer experience.
Next#
- Sharing libraries: one React instance, one store
- Scaling: many modules without bloating the bundle
- Integration: wiring this into a real app layout
