Skip to content

Module registry

How snippet imports map to your app code

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:

tsx
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:

tsx
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 writesPhone book entryWhat 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

tsx
'use client';
import { LiveProvider, LivePreview, defineLoader } from 'next-live';
<LiveProvider
code={source}
modules={{
'@app/format': defineLoader(() => import('@/lib/format')),
}}
>
<LivePreview />
</LiveProvider>

2. The snippet uses a normal import

tsx
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:

tsx
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.

What fails, and what the error looks like#

If a snippet imports something you forgot to register:

tsx
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:

Scope globals
<LiveProvider
code={code}
scope={{ useState, Button, formatMoney }}
/>
Snippet (no imports allowed)
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:

next-live style
<LiveProvider
code={code}
modules={{
'@app/ui': defineLoader(() => import('@/components/ui')),
'@app/format': defineLoader(() => import('@/lib/format')),
}}
/>
Snippet (normal imports)
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):

tsx
'@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):

tsx
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.

lib/live-sdk/
index.ts # export liveModules (one import for LiveProvider)
ui-modules.ts # manual group: @app/ui
format-modules.ts # manual group: @app/format
store-modules.ts # manual group: @app/store
vendor.ts # third-party / prefix loaders
app-modules-glob.ts # optional: auto-register from ./modules/*.ts
modules/
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:

lib/live-sdk/format-modules.ts
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:

lib/live-sdk/index.ts
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:

app/apps/Runner.tsx
'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:

lib/live-sdk/app-modules-glob.ts
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:

tsx
export const liveModules = createRegistry(
vendorModules, // '@demo/vendor/' prefix loader
appModulesFromGlob, // every file in ./modules/
);

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:

tsx
// lib/live-sdk/modules/ui.ts
export { 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:

  • react
  • react/jsx-runtime
  • react/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:

tsx
// default export (or the module itself if there is no default)
import ui from '@app/ui';
// named exports
import { Button, Card } from '@app/ui';
// namespace
import * as ui from '@app/ui';
// side effects only
import '@app/ui';
Snippet writesGets
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:

tsx
<LiveProvider code={source} props={{ user, theme }} />
tsx
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

tsx
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

tsx
import { format } from 'date-fns'; // only works if YOU register date-fns

Register it explicitly:

tsx
'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):

tsx
'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#