Docs / Websites

Step by step

This walkthrough builds the Acme site against Studio content, with the editor able to open and edit its pages inline. Every step shows the same thing in SvelteKit, React Router and Astro; pick your framework once with the tabs and the whole page follows. Each column is a complete example in the Studio repository — examples/site-svelte, examples/site-shadcn, examples/site-astro — so every file shown here exists and runs.

The Studio editor with the Acme site open in its preview: the Hero block selected on the real page, its fields in the inspector
The Acme home page served by the site on localhost, open inside the Studio editor. The Hero is selected; its headline is editable in place.

1. The project

npx sv create acme-site            # SvelteKit, TypeScript
cd acme-site
pnpm add @raisindb/client @maravilla/studio-content @maravilla/studio-svelte
pnpm add -D @sveltejs/adapter-node  # or @maravilla-labs/adapter-sveltekit on Maravilla Cloud
npx create-react-router@latest acme-site
cd acme-site
pnpm add @raisindb/client @maravilla/studio-content @maravilla/studio-react
# @react-router/serve ships with the template; @maravilla-labs/adapter-react-router on Maravilla Cloud
pnpm create astro@latest acme-site -- --template minimal
cd acme-site
pnpm add @raisindb/client @maravilla/studio-content @maravilla/studio-astro @astrojs/node
# @maravilla-labs/adapter-astro on Maravilla Cloud

@maravilla/* packages come from the Maravilla registry; add to .npmrc:

@maravilla:registry=https://solutas.registry.maravilla.cloud/-/npm

Server rendering is required everywhere — the URL is resolved per request, and the editor’s preview session is a cookie. Pin the dev port and allow CORS so Studio can open the site in its iframe:

// vite.config.ts
export default defineConfig({
  plugins: [sveltekit()],
  server: { port: 5191, strictPort: true, cors: true },
});
// react-router.config.ts → { ssr: true }
// vite.config.ts
export default defineConfig({
  plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
  server: { port: 5196, strictPort: true, cors: true },
});
// astro.config.mjs
import node from '@astrojs/node';
export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  server: { port: 4321 },
});

2. Configuration

Everything the site needs to find its content is an environment variable, read on the server only. The names are the same in all three examples:

# .env
RAISIN_HTTP_URL=https://<tenant>.rdb.maravilla.cloud
RAISIN_REPOSITORY=studio
RAISIN_WORKSPACE=stories
RAISIN_ROOT_PATH=/acme
RAISINDB_API_KEY=…                # a service key for reads; never public
RAISIN_LIVE_BRANCH=main           # `publish` once the site publishes
RAISIN_WORKING_BRANCH=main
STUDIO_PREVIEW_SECRET=…           # signs the editor's preview cookie
RAISIN_DEFAULT_LOCALE=en
RAISIN_SUPPORTED_LOCALES=en,de,fr

Locally that is a .env file (on a Maravilla project, maravilla dev writes the API key for you — see Studio on a Maravilla project). On Maravilla Cloud, set them per environment with the CLI:

maravilla variable set RAISIN_HTTP_URL --env production
maravilla secret set RAISINDB_API_KEY --env production
maravilla secret set STUDIO_PREVIEW_SECRET --env production

How the file reaches the code differs: SvelteKit reads $env/dynamic/private, Astro import.meta.env, and React Router needs a small loader for process.env in development (app/lib/env.server.ts in the example) because Vite exposes only VITE_* on its own.

3. Whose draft, which branch

One decision per request: is this the editor’s preview (working branch, the author’s draft overlaid) or a visitor (live branch)? resolveStudioPreview answers it from the signed cookie, readBranch turns it into a branch, and a request scope carries both so no read can forget.

// src/routes/[...path]/+page.server.ts — resolved at the top of the loader
export const load: PageServerLoad = (event) =>
  resolveStudioPreview(event.request, STUDIO_PREVIEW_SECRET ?? '').then((session) =>
    withPreviewSession(session, () => loadPage(event, session), readBranch(event.url, session)),
  );
// app/routes/page.tsx — resolved at the top of the catch-all loader
export async function loader(args: Route.LoaderArgs) {
  const session = await resolveStudioPreview(args.request, STUDIO_PREVIEW_SECRET ?? '');
  const branch = readBranch(new URL(args.request.url), session);
  return withPreviewSession(session, () => loadPage(args, session), branch);
}
// src/middleware.ts — resolved once, inherited by every read
export const onRequest = defineMiddleware(async (context, next) => {
  const session = await resolveStudioPreview(context.request, STUDIO_PREVIEW_SECRET ?? '');
  const scope = { session, readBranch: readBranch(context.url, session) };
  context.locals.studio = scope;
  return withScope(scope, () => next());
});

4. The data layer

A server-only module: one shared RaisinClient.forSSR with the service key as a Bearer token, a query() on the request’s branch, and the reads. The shape is the same in all three examples and moves between them unchanged; the Astro example shows it most compactly:

const COLS = 'id, path, name, node_type, archetype, resolve(properties, 2) AS properties';

export async function getPage(site, slug, locale, variant) {
  const rows = await query(`SELECT ${COLS} FROM '${site.workspace}' WHERE path = $1${localeClause(locale)} LIMIT 1`, [slugToPath(site, slug)]);
  const { node } = await loadPageVariation(rows[0] ?? null, { fetchRows: query, workspace: site.workspace, localeSql: localeClause(locale), columns: COLS, ctx: variant.ctx, force: variant.force });
  return overlayDraft(node, site.workspace, locale);
}
export function getChrome(site, locale) { /* story + header + footer + nav in one read, cached per branch/root/locale */ }
export async function getArticles(site, sourcePath, locale) { /* the query behind standard:ArticleList */ }

resolve(properties, 2) means the hero’s call-to-action and the figure’s image arrive as nodes. Two things SQL cannot do stay in code: signing assets for display (signAssets, once per distinct asset) and resolving the raw references an author’s unsaved draft overlays (resolveRawRefs). The SvelteKit and React Router examples do the same resolution with the shared resolveRefs helper from @maravilla/studio-content/resolve instead of SQL; both are fine, SQL is fewer round trips.

5. The route

One catch-all route: find the site by host, parse the locale, read the page and the chrome, pick variants, sign assets, render.

// src/routes/[...path]/+page.server.ts — loadPage
const site = rootOverride ? { workspace: 'stories', root_path: rootOverride } : await resolveSite(request.headers.get('host'));
const { locale, rest } = parseLocale(`/${params.path ?? ''}`, url.searchParams);
const node = await getPageByPath(site, slug, locale, { ctx: { locale, query }, force: url.searchParams.get('studio-variant') });
const content = await resolveRefs(node.properties.content ?? [], locale);
return { content, title: node.properties.title, locale, query, editMode };
// app/root.tsx loader — the chrome, once per navigation
const [story, headerPage, footerPage, navItems] = await Promise.all([
  getStoryNode(site), getReservedPage(site, 'header', queryLocale), getReservedPage(site, 'footer', queryLocale), getNavTree(site, queryLocale),
]);
// app/routes/page.tsx loader — the page
const node = await getPageByPath(site, slug, queryLocale, { ctx: { locale, query, audience }, force: url.searchParams.get('studio-variant') });
await enrichArticles(node, { site, locale: queryLocale, ctx, url });
const properties = await resolveRefs(node.properties ?? {}, queryLocale);
// src/pages/[...path].astro
const site = await resolveSite(Astro.request.headers.get('host'), url.searchParams.get('root'));
const { locale, rest } = parseLocale(`/${Astro.params.path ?? ''}`, url.searchParams);
const variantCtx = variantCtxFor(Astro.request, { locale, now: new Date() }); // country, device, campaign + query
const [page, chrome] = await Promise.all([
  getPage(site, slug, queryLocale, { ctx: variantCtx, force: url.searchParams.get('studio-variant') }),
  getChrome(site, queryLocale),
]);
const { content } = pickVariantContent(page?.properties?.content ?? [], variantCtx, editing);
await signAssets([content, chrome.header?.properties, chrome.footer?.properties]);

Two details that are easy to miss. Variant wrappers are spliced away for a visitor but kept in edit mode, so the editor can address every variant (pickVariantContent in the loader, or variantContent / useVariantContent in the component). And a page opened in edit mode with no session yet renders a “connecting” page rather than a 404: the editor’s first paint has no session, reads the live branch, and would otherwise never mount the bridge that gets it one.

6. Blocks

Two registries — element type to block component, archetype to page layout — and a recursion that renders a content array. Each block’s fields become props under their YAML names.

<!-- src/lib/elements/Blocks.svelte -->
{#each blocks as block (block.uuid)}<Element element={block} />{/each}

<!-- src/lib/elements/Hero.svelte -->
<section class="hero" data-studio-id={uuid} data-studio-type="standard:Hero" data-studio-drop="background_image" data-studio-drop-accept="asset">
  <h1 class="hero-title" data-studio-field="headline">{headline}</h1>
  <div class="hero-sub" data-studio-field="subheadline">{@html richtextToHtml(subheadline)}</div>
  <a href={href} data-studio-field="cta_label" data-studio-drop="cta" data-studio-drop-accept="page">{cta_label}</a>
</section>
// app/components/elements.tsx
export function renderElements(content: El[] = []) {
  return content.map((el) => { const Comp = registry[el.element_type] ?? Fallback; return <Comp key={el.uuid} {...el} />; });
}

// app/components/blocks/marketing.tsx
<section className="hero" {...studioAttrs(el)} data-studio-drop="background_image" data-studio-drop-accept="asset">
  <h1 data-studio-field="headline">{el.headline}</h1>
  <RichText html={el.subheadline} field="subheadline" />
  <a href={href ?? '#'} data-studio-field="cta_label" data-studio-drop="cta" data-studio-drop-accept="page">{ctaLabel}</a>
</section>
---
// src/components/Blocks.astro
const { blocks = [], editing = false, ctx = {} } = Astro.props;
---
{blocks.map((el) => { const Comp = componentFor(el?.element_type); return <Comp {...el} editing={editing} ctx={ctx} />; })}

<!-- src/components/blocks/Hero.astro -->
<section class="hero" data-studio-id={uuid} data-studio-type="standard:Hero" data-studio-drop="background_image" data-studio-drop-accept="asset">
  <h1 class="hero__title" data-studio-field="headline">{headline}</h1>
  <RichText class="hero__sub" value={subheadline} data-studio-field="subheadline" />
  <a class="button" href={hrefOf(target)} data-studio-drop="cta" data-studio-drop-accept="page"><span data-studio-field="cta_label">{cta_label}</span></a>
</section>

A container block renders its children through the same recursion inside an element marked data-studio-section={uuid} data-studio-section-name="blocks", which makes it a drop target. Elements that take a dropped image or page directly carry data-studio-drop — the inline editing page explains the pairing with the type’s dropOnto. Rich text goes through the adapter’s RichText, which renders either storage format safely.

7. The editor

Mount the editing agent once, mark the editable region, and tell it how to re-render after a structural change. Nothing here renders for a visitor.

<!-- src/routes/[...path]/+page.svelte -->
<script>
  const editor = studioEditor({
    mode: 'server',
    initialContent: () => data.content,   // getters, so invalidateAll resyncs
    initialDoc: () => ({ title: data.title }),
    onRevalidate: invalidateAll,
  });
  const view = variantContent(() => editor.content, () => ({ locale: data.locale }), () => data.editMode);
</script>
<main data-studio-root>
  <h1 data-studio-doc-field="title">{editor.doc.title}</h1>
  <div data-studio-section="root" data-studio-section-name="content"><Blocks blocks={view.content} /></div>
</main>
// app/routes/page.tsx
const { content, doc } = useStudioEditor({
  mode: 'server',
  initialContent: data.page.properties.content ?? [],
  initialDoc: { title: data.page.title },
  onRevalidate: () => revalidator.revalidate(),
});
const viewContent = useVariantContent(content, { locale: data.locale, audience: data.audience }, data.editMode);
// app/root.tsx
<div data-studio-root><SiteHeader /><main><Outlet /></main><SiteFooter /></div>
<!-- src/layouts/Site.astro -->
<main id="studio-page" data-studio-root data-studio-editing={editing ? 'true' : undefined}>
  <slot />
</main>
<StudioEditor />   <!-- from @maravilla/studio-astro; re-fetches the page and swaps #studio-page on revalidate -->

8. The preview endpoints

The editor exchanges a short-lived ticket for the signed cookie the site reads in step 3. Three primitives from @maravilla/studio-content/preview-session do the work; the route just dispatches on the action.

// src/routes/studio-preview/[action]/+server.ts
export const POST: RequestHandler = async ({ params, request }) => {
  if (params.action === 'enter') return studioPreviewEnter({ request, secret: STUDIO_PREVIEW_SECRET ?? '', readGrant });
  if (params.action === 'leave') return studioPreviewLeave();
  return new Response('Not found', { status: 404 });
};
export const GET: RequestHandler = async ({ params, request }) =>
  params.action === 'status' ? studioPreviewStatus(request, STUDIO_PREVIEW_SECRET ?? '') : new Response('Not found', { status: 404 });
// app/routes/studio-preview.tsx — registered BEFORE the catch-all in app/routes.ts
export async function action({ params, request }: Route.ActionArgs) {
  if (params.action === 'enter') return studioPreviewEnter({ request, secret: STUDIO_PREVIEW_SECRET ?? '', readGrant });
  if (params.action === 'leave') return studioPreviewLeave();
  return new Response('Not found', { status: 404 });
}
export async function loader({ params, request }: Route.LoaderArgs) {
  return params.action === 'status' ? studioPreviewStatus(request, STUDIO_PREVIEW_SECRET ?? '') : new Response('Not found', { status: 404 });
}
// src/pages/studio-preview/[action].ts
import { studioPreviewRoutes } from '@maravilla/studio-astro/preview';
export const prerender = false;
export const { GET, POST } = studioPreviewRoutes({ secret: STUDIO_PREVIEW_SECRET ?? '', readGrant });

9. Tell Studio where the site runs

In Studio, open the story’s settings → Advanced → Embed rules and add a rule: pattern /**, base URL where your dev server listens, locale mode prefix. That is what makes a page open in the editor’s preview instead of the plain form. In the package it is the embed_rules entry on the story node, with the base URL as an environment token so nothing is hardcoded:

  domain: "{env:SITE_DOMAIN:-acme.localhost}"
  embed_rules:
    - uuid: embed-default
      element_type: studio:EmbedRule
      pattern: /**
      base_url: "{env:PREVIEW_SERVER:-http://localhost:4321}"
      locale_mode: prefix

Locally the default applies. On the tenant you deploy with PREVIEW_SERVER=https://acme.example SITE_DOMAIN=acme.example exported, or -e production to read them from .env.production; Modelling a site has the details, the inline editing page the rules and their traps.

FrameworkDev server
SvelteKitpnpm dev → http://localhost:5191
React Routerpnpm dev → http://localhost:5196
Astropnpm dev → http://localhost:4321
The story settings in Studio, Advanced tab, with the embed rule pointing at the local dev server
The story's embed rule: every page under the story opens the local dev server in the editor's preview.

10. Run it

Open the story’s home page in Studio. The preview shows your site, says “Ready to edit”, and a click on the hero selects it. Type into the headline; duplicate a block from its toolbar; drag an image from the Media rail onto the hero; switch the language in the app bar and watch the preview reload in that language.

The Studio editor switched to German, previewing the site with the ?lang parameter
Translation mode: the editor reloads the preview in the chosen language; fields marked translatable become editable for that language.

11. Deploy

On Maravilla Cloud: install the framework’s @maravilla-labs/adapter-*, set the site’s variables with maravilla variable set and maravilla secret set, push. Deploy the content package with PREVIEW_SERVER and SITE_DOMAIN set to the deployed host, so the story’s domain and embed rule point at it. Switch RAISIN_LIVE_BRANCH to publish when the team starts using publishing; until then the live site shows the working branch.

Anywhere else: build, run the server output behind your proxy. The site needs to reach RaisinDB over HTTPS and nothing else.

Pilot documentation. Studio is in a partner pilot. Screens and YAML shown here are current as of the version noted on each page; tell your Maravilla contact when something does not match what you see.