Build with Osmose.

Install the CLI, compose Liquid components, add framework islands, and ship a Shopify-native theme.

Documentation is public. Installing the beta still requires a license.

Start reading

Getting started

Osmose compiles Liquid components and optional client-side islands into a Shopify-native theme. Development runs against a Shopify store; it is not an offline storefront renderer.

This page covers installation, scaffolding, store configuration, and the dev loop.

Install

Request private-beta access at osmose.sh, then replace YOUR_BETA_KEY below with your approved key. Treat the keyed URL as a credential.

On macOS or Linux:

curl -fsSL 'https://get.osmose.sh/install.sh?key=YOUR_BETA_KEY' | bash

On Windows, in PowerShell:

irm 'https://get.osmose.sh/install.ps1?key=YOUR_BETA_KEY' | iex

The default destination is ~/.local/bin ($HOME\.local\bin on Windows). Follow the installer's PATH instructions and open a new terminal if needed. Editor integration installation is optional.

The Bash installer checks SHA-256 when the checksum file, matching entry, and hashing tool are available; it warns and continues if they are unavailable. The PowerShell installer does not perform that checksum check. Neither installer verifies a release signature. Activate your machine explicitly after installation:

osmose --version
osmose login

login uses your beta key, not your Shopify account. Released builds require an active license for development, builds, packaging, and store operations. CI can provide OSMOSE_LICENSE_KEY for headless activation. Scaffolding with new, diagnostics, and update checks do not require activation.

Prerequisites

Install a current Node.js 22 LTS patch release and a supported package manager (npm, pnpm, yarn, or bun) for Vite and client-side integrations. The scaffolder downloads Shopify's skeleton, so it needs network access; dependency installation also needs access to your package registry.

The Shopify CLI backend and build --check additionally require Shopify CLI:

npm install -g @shopify/cli

An existing theme needs its Vite/framework dependencies installed too; init only writes configuration and performs access setup, not a template or dependency scaffold.

Scaffold a project

For a new theme:

osmose new my-theme --yes --pm npm --skip-store
cd my-theme

This downloads the Shopify skeleton, overlays the Osmose starter (including components/, package.json, and osmose.toml), and runs npm install. The starter has a development environment with empty store credentials. --yes skips interactive prompts; --skip-install also skips dependency installation, in which case run npm install inside the project before development. Use a new or empty target directory. --force permits overwriting an existing one, and scaffolding removes the target's .git directory; do not use it to adopt an existing project.

Configure a store

Use a disposable, unpublished theme while developing. In the generated osmose.toml, fill in the existing environment's storefront_url with your your-store.myshopify.com host and theme_id with its numeric theme ID. For token-based access, set access_token to a Theme Access password or an Admin API token authorized for theme operations. Keep credentials out of version control.

Alternatively, leave access_token empty and sign in to your Shopify account:

osmose auth login

This is separate from osmose login. The account must have access to the store. The default theme_backend = "auto" chooses GraphQL when a usable token or Osmose Shopify sign-in is available, otherwise Shopify CLI with its own access setup.

For an existing theme without an Osmose config, supply your real store, theme ID, and Theme Access token in the following environment variables, then run:

# Set SHOPIFY_FLAG_STORE, SHOPIFY_FLAG_THEME_ID, and SHOPIFY_CLI_THEME_TOKEN first.
osmose init

init creates a local config with a dev environment. It requires a store and theme ID; it is not an interactive credential wizard. --skip-auth skips access setup, not those required fields. Do not use init --force to add an environment: it replaces the config. Edit the existing file instead.

osmose config lists configured environments, or writes a placeholder config if none exist; it does not open an editor or prompt for credentials. Add named [environments.<name>] tables manually and select one with the top-level active = "<name>" setting or the TUI environment switcher. There is no global --env flag (init --env only names the environment being created).

Start the dev loop

From the configured theme's source directory:

osmose doctor
osmose doctor --remote
osmose dev

doctor checks local tooling, theme structure, and configuration; --remote also contacts Shopify to check access without uploading theme files.

dev starts a watcher, attempts to start Vite, and starts a local WebSocket server. It compiles and syncs on startup, not only after the first save. With the Shopify CLI backend it also runs shopify theme dev against a generated mirror. Open the configured theme's Shopify preview or the Shopify CLI preview URL shown in the terminal; do not depend on a browser opening automatically.

Development writes to the target theme, including runtime assets and subsequent file changes. Keep the process running and allow the preview browser to reach the local dev servers. Missing Node/Vite is reported rather than always fatal, but client development requires a working Vite server.

Section updates can replace rendered sections in place, and client modules use Vite HMR. Layout/template changes, broader dependencies, or failed section refreshes can require a full page reload; not every save preserves page state.

Your first component

Create components/price-badge/server.liquid. This example is server-only; add a client entry only when you need browser behavior.

components/
  price-badge/
    server.liquid

server.liquid declares its typed contract and its markup:

{% interface PriceBadgeProps %}
  amount: number
  currency: string
{% endinterface %}
{% props PriceBadgeProps %}
  currency: "USD"
{% endprops %}

<span class="price-badge">
  {{ amount | money }} <span>{{ currency | escape }}</span>
</span>

Mount it from any section or layout. Pass amounts in the same units as product.price; currency here is a display label, not currency conversion.

{% component "price-badge", amount: 3900, currency: "USD" %}

Save the section and inspect the configured theme preview. The next page, components, covers the tag syntax, props contract, and hydration strategies in full. To produce deployable files or upload a production build, continue to deploying.

Back to documentation contents

Components

Components are the unit of composition in an Osmose theme. Each one lives under components/<kebab-name>/, with a required server template and an optional client entry:

components/product-card/
  server.liquid      # server-rendered Liquid + typed props
  client.ts          # optional client-side island script

Mount a component from any section, layout, or other component using the {% component %} tag. The tag is compiled away at build time. Server markup becomes standard Shopify Liquid; interactive islands also include browser assets.

Use one client entry per directory and a valid custom-element name containing a hyphen, such as product-card. The examples below all use that directory.

The component tag

{% component "product-card", title: product.title, price: product.price %}

For a typed component, compilation:

  1. Locates components/product-card/server.liquid and validates the call against its named props contract.
  2. Binds supplied values and omitted-input defaults to private Liquid names.
  3. Inlines the component body, with its props and locals namespaced per invocation.

This is a build-time rewrite, not a separate network request or a generated Shopify snippet. Contract declarations are erased from Liquid output; no theme snippets or blocks are generated for them. Client type declarations are described below.

Typed props

Typed server inputs are opt-in: declare an interface, then select it with {% props InterfaceName %}. Interface members declare types; the named props body supplies Liquid default expressions, not JavaScript or type annotations.

{% interface CardProps %}
  title: string
  featured: boolean
  price: number
  label: string
  subtitle?: string
{% endinterface %}

{% props CardProps %}
  featured: false
  price: 0
  label: ''
{% endprops %}

<article>
  <h2>{{ title | escape }}</h2>
  {% if subtitle %}<p>{{ subtitle | escape }}</p>{% endif %}
  {% if featured %}<strong>Featured</strong>{% endif %}
  <span>{{ price | money }}</span>
  <span>{{ label | escape }}</span>
</article>

Mount this product-card with the required title and any overrides:

{% component "product-card", title: product.title %}
{% component "product-card", title: "Sale", featured: true, price: 1200 %}
  • title has neither ? nor a default, so every call must supply it.
  • A valid default makes an input omittable at the call site, but its resolved value remains definite: featured, price, and label are still boolean, number, and string. Defaults apply only to omitted inputs, not falsy values; explicit false, 0, and '' are preserved.
  • subtitle?: string permits omission, which resolves to Liquid nil. It does not permit an explicit subtitle: nil argument. Declare subtitle?: string | nil to accept both omission and explicit nil. A required subtitle: string | nil accepts nil but still requires an argument.

Top-level names in the interface selected by named props cannot be the exact lowercase Liquid literals true, false, nil, null, empty, or blank: Liquid evaluates these as constants, not server input bindings. Nested object keys, unselected interface members, and bare props payload keys are unaffected. Use a name such as label for an input whose default is ''.

load, media, client, target, and trigger are reserved island directive names, not props. They cannot be interface members or named defaults.

Types include primitives, literal unions, arrays, local interfaces, and Shopify Liquid objects such as Product. The syntax is data-only, not arbitrary TypeScript: string[], ('small' | 'large')[], and structural objects are supported, but TypeScript generics, functions, and interface inheritance are not.

Defaults may reference other props and are evaluated in dependency order, not declaration order; dependency cycles are errors. A same-name reference is deliberately ambient:

{% interface ProductProps %}
  product: Product
  title: string
{% endinterface %}
{% props ProductProps %}
  title: product.title
  product: product
{% endprops %}

Here product: product reads the caller/global Product before private inputs are initialized, and title reads the resolved product, including a caller's override. Used defaults are checked against known caller types: shadowing the ambient product with a number is an error when this default is needed. Explicitly supplying a valid product bypasses that unused default.

In a named props body, separate the next default after an unwrapped filter pipeline with a newline or semicolon: commas belong to Liquid filter arguments. For example:

{% props CardProps %}
  label: "Sale" | append: "!"
  price: 0
  featured: false
{% endprops %}

Component arguments remain comma-separated, including after a filtered value:

{% component "product-card", title: product.title | append: "!", price: product.price %}

Bare props compatibility

An unnamed {% props %} block retains its existing raw JavaScript-plus-Liquid client payload behavior and best-effort inference:

{% props %}
{% assign json_escape = 'XHUwMDNj' | base64_decode %}
  title: {{ product.title | json | replace: '<', json_escape }},
  featured: false,
{% endprops %}

This body is passed through as an object expression, not a typed server contract. Its keys create no server bindings, and invocation arguments are not merged into the payload as defaults; existing caller-argument assignments remain separate. Bare JavaScript syntax is unchanged. Declaring an interface alone does not opt in: only a named props block enables the typed contract.

The compiler emits the legacy payload before the component body. Keep Liquid preparation inside the {% props %} block, as above; an assign elsewhere in the component body runs too late to prepare its seed.

Diagnostics and editor support

The compiler and LSP report malformed declarations/defaults, unknown types, duplicate or unknown inputs, missing required arguments, and provable type mismatches. Calls are checked at their authored source locations, including nested calls whose inputs come from known props, property paths, assignments, or loop aliases. Unsaved contract edits propagate to dependent caller diagnostics. Completion offers interfaces, types, and props; hover explains declared types, defaults, and requiredness; definition navigation links calls and prop references back to their declarations.

This is static checking, not Liquid execution. Unresolved globals, dynamic lookups, stateful tags, or filters with unknown results may remain unknown rather than produce a type mismatch. Conditions do not narrow types; a successful check does not prove an unknown runtime value satisfies the contract.

Liquid scope and supported constructs

Typed props and ordinary locals (assign, capture, and loop aliases) receive private names. Locals reset on every execution, including repeated execution of the same call inside a loop. Nested components cannot overwrite a parent's bindings. Legacy children inside a typed subtree also have their executable Liquid names isolated, including Liquid embedded in a bare props payload; the JavaScript text itself remains raw. Legacy-only trees retain their existing scope behavior. Free reads can still see caller/global values; isolation protects bindings that the component writes, rather than creating a Shopify render-style scope. Explicitly passing an input is clearer than depending on an ambient caller local. Loop aliases are lexical: an outer input, local, or global remains available before and after the loop, and in its empty-loop else branch. assign and capture write the component's root-local binding even when an active loop alias shadows reads of the same name.

Ordinary conditionals, component-owned for/tablerow loops, and multiline {% liquid %} statements are supported, but isolation is not a promise to accept every native Liquid construct. Unsupported scope or state semantics are explicit compile errors:

  • break/continue cannot target an outer caller's loop. Implicit loop, form, and pagination objects require their owning tag inside the component.
  • forloop requires a static property access; whole-object or dynamic access, and parentloop traversal outside component-owned loops, are rejected.
  • include shares dynamic caller scope and is rejected; use literal-target render with explicit arguments. Dynamic render/component targets and unknown tags with unrecognized binding semantics are rejected.
  • offset: continue and ifchanged use persistent native state and are rejected.
  • increment, decrement, and explicitly literal-grouped cycle are lowered to resettable private state only as standalone tags outside for, tablerow, form, and paginate. They are rejected inside those scopes or a {% liquid %} block. Implicit/dynamic cycle groups and filtered cycle values are unsupported.

Client values and Shopify types

Named props serialize the resolved values into the island's <script data-props> seed. Every declared key is present: omitted optional values become JSON null, not absent keys or undefined. Serialization tests for nil explicitly, preserving false, zero, and empty strings, and escapes < so serialized values cannot terminate the script element. The seed is a JavaScript object expression containing JSON values, not a standalone JSON document; the browser evaluates it rather than calling JSON.parse. Bare props are not automatically escaped: apply json and script-safe escaping to untrusted Liquid output in a legacy payload.

The generated root osmose.d.ts exposes two views:

  • ComponentInputs["product-card"] exposes caller requiredness: defaulted and optional inputs may be omitted. Its value types are conservative, JSON-safe approximations; the compiler and LSP check the actual declared Liquid types.
  • Component-specific Props interfaces describe resolved client .props: every typed key exists, and optional-without-default values include null.

Shopify Liquid types come from the bundled Shopify object/filter registry, with a validated user-cache revision preferred when available. The LSP automatically checks for registry updates in the background; failed or offline refreshes keep the last good data. Compiler lookups use the available registry snapshot. No project/theme type regeneration or additional generated files are needed to refresh this registry.

A Liquid Product is a server Drop, not a promise about the object produced by Shopify's json filter. Whole Drops and structural object values therefore have an honest unknown client wire type (arrays of Drops become unknown[]), not invented TypeScript Shopify interfaces. Project scalar fields such as title: product.title and price: product.price into typed props when client code needs those fields.

Native client contract types

Osmose generates osmose.d.ts from the server contracts during build and dev; the Liquid LSP also updates it as contracts change, including unsaved edits. You do not need to launch the LSP before building. Treat the declaration file as generated output, not a second place to maintain your interface.

Bind the generated resolved type using your framework's normal TypeScript API. For components/product-card/client.tsx, this React example selects React's automatic JSX runtime explicitly:

/** @jsxImportSource react */
import type { ProductCardProps } from '../../osmose';

export default function ProductCard({ title, price }: ProductCardProps) {
  return <span>{title}: {price}</span>;
}

For Preact, use the same function with /** @jsxImportSource preact */ instead. Both require the matching integration and a TypeScript configuration with "jsx": "react-jsx"; see native type checking.

The name comes from the component (product-card → ProductCardProps), not the name of the Liquid interface selected by {% props %}. Use this resolved type for client values, not ComponentInputs["product-card"]: caller inputs can omit defaulted keys, while the client receives those keys with definite values.

Native web components and Lit can declare their payload without emitting a field initializer that would overwrite the runtime value:

import type { ProductCardProps } from '../../osmose';

class ProductCard extends HTMLElement {
  declare props: ProductCardProps;

  connectedCallback() {
    this.textContent = this.props.title;
  }
}
customElements.define('product-card', ProductCard);

For Lit, declare the same container inside a complete LitElement class:

import { LitElement, html } from 'lit';
import type { ProductCardProps } from '../../osmose';

class ProductCard extends LitElement {
  declare props: ProductCardProps;

  render() {
    return html`<span>${this.props.title}: ${this.props.price}</span>`;
  }
}
customElements.define('product-card', ProductCard);

If you prefer named Lit properties, declare them in static properties and type each from the generated type, such as declare price: ProductCardProps['price']. Declaring a TypeScript field alone does not make it reactive. Native DOM names, methods, and readonly properties are protected from automatic named assignment; the full payload remains available in .props. For Solid, annotate the customElement() callback parameter:

/** @jsxImportSource solid-js */
import { customElement } from 'solid-element';
import type { ProductCardProps } from '../../osmose';

customElement('product-card', (props: ProductCardProps) => {
  return <span>{props.title}: {props.price}</span>;
});

Solid's native checker uses "jsx": "preserve" and "jsxImportSource": "solid-js"; Vite's Solid plugin performs the JSX transform.

Vue 3.3+ single-file components can import the type into their native props API:

<script setup lang="ts">
import type { ProductCardProps } from '../../osmose';
const props = defineProps<ProductCardProps>();
</script>

<template>
  <span>{{ props.title }}: {{ props.price }}</span>
</template>

Svelte 5 uses its native props rune:

<script lang="ts">
import type { ProductCardProps } from '../../osmose';
let { title, price }: ProductCardProps = $props();
</script>

<span>{title}: {price}</span>

JavaScript clients can use standard JSDoc, for example @param {import('../../osmose').ProductCardProps} props, with checkJs enabled. Existing author annotations are not rewritten; use or reference the generated type wherever you want the Liquid contract checked.

These small bindings let stock tsc, vue-tsc, and svelte-check, and your normal TypeScript/Vue/Svelte editor extensions, consume the same declarations. Enable strict checking (including strictNullChecks) to catch unsafe nullable access. Native rename, references, signature help, formatting, and SFC style tooling remain with their native services. Osmose supplies Liquid tooling and declarations, not a second native client diagnostic service, special client language modes, or an editor takeover.

Type bindings are erased at runtime. Mount-time prop delivery remains automatic, independently of whether a client uses TypeScript or imports a generated type.

These types describe the JSON boundary above, not server Drops. A required Product prop remains unknown on the client; project the scalar fields your client needs. See framework mounting for when values are available and which registration forms are supported.

Imports

{% import %} loads an asset, not a component. Paths are relative to the theme's assets/ directory:

{% import "theme.css" %}
{% import "theme.js" %}

Create assets/theme.css and browser-ready assets/theme.js before using these examples. CSS imports emit stylesheet links; script imports emit module script tags. Development points to Vite, while production uses Shopify asset URLs. Linked script imports keep their source filenames and contents in production; they do not turn a .ts URL into compiled JavaScript. To compile a TypeScript asset into the page, use the supported inline path:

{% import "theme.ts" | inline %}

{% import "theme.css" | inline %} embeds CSS in production; dev keeps it linked. Without Tailwind, plain CSS is returned as authored. Precompile Sass/SCSS/Less to CSS before linking: the current non-Tailwind inline style path also returns preprocessor source unchanged.

Components need no import declaration: {% component "product-card", title: "Sale" %} resolves the directory directly and records its use for the dependency graph. {% import "product-card" %} is invalid because it has no supported asset extension.

Use normal JavaScript imports inside a client entry for dependencies and styles. Framework-local styles follow that framework's DOM model: Lit, Vue custom elements, and Svelte custom elements normally use shadow DOM, which page-level CSS does not cross. Load CSS needed by the initial Liquid markup separately; do not rely on a deferred client import for the first server-rendered paint.

Hydration strategies

Adding a client.* entry creates an island. Supported extensions are .js, .jsx, .mjs, .cjs, .ts, .tsx, .mts, .cts, .vue, and .svelte. Named contract metadata and generated client types cover all of these entries.

Osmose emits an <island-element> around the component's custom-element tag and server markup. With a client file, omitting load: defaults to idle:

  • idle — schedule with requestIdleCallback, falling back to a short timer.
  • load or eager — schedule on the next animation frame after the wrapper connects; neither waits for the window load event.
  • visible — use an intersection observer configured with a 1.0 threshold; it starts loading when a delivered entry is intersecting. This is not a configurable near-viewport prefetch margin.
  • media — check media: on connection, then on window resize until it matches. Changes without a resize, such as a color-scheme change, are not watched. Without a media query it loads immediately.

Pick the strategy at the mount site, still supplying required typed inputs:

{% component "product-card", title: product.title, load: "idle" %}
{% component "product-card", title: product.title, load: "eager" %}
{% component "product-card", title: product.title, load: "visible" %}
{% component "product-card", title: product.title, load: "media", media: "(min-width: 768px)" %}

Without a client file, the compiler emits the component tag and server content without an island wrapper. There is no documented server-only switch for a component that has a client file. Unknown nonempty strategies currently load immediately rather than disabling JavaScript; use the listed values.

load and media configure loading. target and trigger are optional literal CSS selectors describing elements an island controls and controls that open it; they supply visual-ownership hints to the editor, not event listeners. client is reserved for compatibility but does not select an entry or disable hydration. None of these directives becomes a Liquid prop binding.

Server vs client

The compiled contents of server.liquid run on Shopify when the theme renders, with the Liquid globals available in that rendering context, subject to the typed component scope restrictions above. The browser prepares the <script data-props> seed, imports the client module unless the custom element is already registered, installs the resolved values, and replaces the island wrapper with the component element. Register the matching tag or use a supported automatic registration form; an ordinary script that never registers the tag cannot mount an island.

The rule of thumb: server-render everything you can, and reach for client.ts only when a component genuinely needs interactivity, state, or a framework primitive. The initial paint should show the correct content without JavaScript. This is island activation, not a guarantee of framework SSR hydration: React/Preact mount a new client tree, and a shadow-DOM framework may replace or hide the Liquid fallback. Design and style both states deliberately.

Back to documentation contents

Frameworks

Osmose supports native custom elements plus Lit, Preact, React, Vue, Svelte, and Solid client entries. Tailwind is available for styling. Each island must ultimately register a custom element whose tag matches its component directory.

Supported integrations:

  • Native custom elements: browser APIs in JavaScript or TypeScript; no framework package.
  • Lit: web components authored in TypeScript.
  • Preact: JSX components and preact-custom-element registrations.
  • React: JSX components with react and react-dom.
  • Vue: .vue single-file components via @vitejs/plugin-vue.
  • Svelte: .svelte components via @sveltejs/vite-plugin-svelte.
  • Solid: JSX components with solid-js and solid-element.
  • Tailwind: utility-first CSS via @tailwindcss/vite.

Auto-detection

Osmose preserves the active environment's configured integrations and adds frameworks detected from package.json dependencies/devDependencies or immediate components/*/client.* filenames:

| Integration | Package signals | Filename signal | Required Vite plugin | | --- | --- | --- | --- | | Preact | preact, preact-custom-element | None | @preact/preset-vite | | React | react, react-dom | None | @vitejs/plugin-react | | Vue | vue | .vue | @vitejs/plugin-vue | | Svelte | svelte | .svelte | @sveltejs/vite-plugin-svelte | | Solid | solid-js, solid-element | None | vite-plugin-solid |

Filename detection does not install packages. JSX imports help the mount transform distinguish React from Preact; they do not independently configure a Vite integration. .tsx alone does not identify a framework.

Lit and native custom elements do not need a dedicated Vite plugin. A saved lit integration is accepted without loading one. Tailwind is not package-auto-detected; osmose add tailwindcss records its configuration as shown below. These plugins run with Osmose's built-in options, not per-component include/exclude rules; see mixing frameworks.

Adding a framework

Use the add subcommand to install and wire up a new framework:

osmose add lit
osmose add preact
osmose add react
osmose add vue
osmose add svelte
osmose add solid
osmose add tailwindcss

osmose add selects the package manager from project/global configuration or package-manager detection (npm, pnpm, yarn, bun), then installs the integration's dependencies. It then attempts to save the integration in the active environment, printing a warning if configuration persistence fails. It does not rewrite client source or tsconfig.json. Package detection also enables the corresponding JavaScript integration; Tailwind depends on its configured entry.

The command also supplies missing Vite, TypeScript, and cjs-module-lexer tooling without changing existing pins. React adds its runtime, Vite plugin, and React type packages; Vue adds vue-tsc, and Svelte adds svelte-check. React is also available when scaffolding a new project. osmose deps reports any required package a project lacks, osmose deps --install adds them, and osmose update runs that reconciliation after installing a new version. If configuring a project by hand, install TypeScript even for JavaScript-only islands: Osmose's mount transform uses its parser. cjs-module-lexer is required for production framework bundles: it discovers CommonJS exports without executing packages, so native named imports such as React hooks remain available through the import map.

Native type checking

Runtime registration and prop delivery below do not require type annotations. For editor and compiler checking, bind the generated resolved type from the root osmose.d.ts using standard TypeScript:

  • Native web components and Lit: declare props: ProductCardProps.
  • React and Preact: function ProductCard(props: ProductCardProps).
  • Solid: (props: ProductCardProps) => … in customElement().
  • Vue: defineProps<ProductCardProps>().
  • Svelte 5: let { title, price }: ProductCardProps = $props().

Import it with import type { ProductCardProps } from '../../osmose' inside components/product-card/client.*. No copied props interface is needed. ProductCardProps describes resolved JSON values; ComponentInputs["product-card"] describes values supplied by a Liquid caller, including omittable defaulted inputs. See client contract types for complete examples.

Build/dev generates these declarations without an LSP prerequisite. Your normal native editor services and stock tsc, vue-tsc, or svelte-check consume them. Keep the usual TypeScript, Vue, and Svelte language modes and extensions; native refactoring, formatting, and style tooling remain available. There is no separate Osmose native diagnostic service to run.

Your checker still needs a native tsconfig.json. For a React client at components/product-card/client.tsx, a minimal starting point is:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "strict": true,
    "exactOptionalPropertyTypes": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "jsxImportSource": "react"
  },
  "include": ["osmose.d.ts", "components/product-card/client.tsx"]
}

Use TypeScript 5+ for this configuration. For Preact change jsxImportSource to preact. For Solid use "jsx": "preserve" and "jsxImportSource": "solid-js". Native custom elements and Lit do not need JSX options. Vue and Svelte need their SFCs included instead of just client.tsx, and their native checkers rather than plain tsc. Separate checker configurations avoid competing JSX namespace types in a mixed-source project; they do not isolate Vite's runtime transforms.

After Osmose has generated osmose.d.ts, run the installed checker from the theme root (these commands assume the matching tsconfig.json):

npx --no-install tsc --noEmit -p tsconfig.json
npx --no-install vue-tsc --noEmit -p tsconfig.json
npx --no-install svelte-check --tsconfig ./tsconfig.json

Choose one command for the corresponding framework, not all three for every project. For JavaScript clients, enable allowJs and checkJs and bind the generated type through JSDoc.

The examples use current native APIs: React 18+ (react-dom/client), Vue 3.3+ (imported defineProps types), and Svelte 5 ($props). A Svelte legacy export let declaration can also reference the generated type, but the automatic contract projection inserts Svelte 5 runes when no props declaration is present. Do not assume these examples support older major versions. Keep each framework, Vite plugin, and checker on mutually compatible versions.

Per-framework specifics

  • Lit and native web components: register a custom element whose tag matches the component directory. Read the full payload from this.props, or declare named properties such as Lit reactive properties. Values are installed after construction and before the supported connected lifecycle; do not read server props in a constructor or field initializer.
  • Preact and React: default-export your client.tsx component. Osmose registers the directory's tag and passes the payload as the component's props. React uses react-dom/client; Preact uses its native renderer. Existing explicit customElements.define() registrations are not auto-wrapped again. If you use preact-custom-element's default import, its two-argument register(Component, 'product-card') form gets inferred contract prop names only when the component has no observedAttributes or propTypes. An explicit observed-props argument wins. Register the directory's actual tag.
  • Vue: a client.vue single-file component is registered through defineCustomElement. Server values arrive as named Vue props. Osmose runs the Vue plugin in custom-element mode so SFC styles can accompany the element.
  • Svelte: a client.svelte component is compiled as a custom element and registered with the directory's tag. An explicit <svelte:options customElement="…" /> or customElement={false} remains an author decision; an explicit tag must match the tag your Liquid island mounts. customElement={false} opts out of the required automatic registration: provide your own registering adapter if the entry is still used as an island.
  • Solid: register the directory's tag with customElement() from solid-element. Contract prop names are merged into its defaults without replacing explicit defaults; the callback receives the server-resolved values. The transform recognizes direct imported calls (including aliased/namespaced imports), not arbitrary wrappers around the registration function.

Values at the first render

When Osmose's runtime is installed before the client element is registered, it defers that element's connected lifecycle while it is inside an island wrapper. The seed is applied before the connected render, including when another island has already registered the same tag. A custom element registered before the Osmose runtime was loaded is outside this lifecycle guarantee.

false, 0, "", null, arrays, and nested objects are values, not signals to use an Osmose fallback. Nested values are not JSON-stringified into HTML attributes. The runtime sets .props plus eligible named properties; it protects native DOM members unless explicitly declared, and does not overwrite methods, getter-only properties, or special keys such as constructor. Read .props for the complete payload in native/Lit elements. Framework-owned prop declarations and coercion still matter; use the generated type with compatible native APIs.

For Vue/Svelte, the build may inject missing runtime-facing props declarations from a named Liquid contract. This does not edit the authored file or make a stock editor infer undeclared variables. Keep the explicit generated-type bindings in source for native checking.

The browser uses the values produced by Liquid, not an independent copy of the server defaults. See typed props for required inputs, omitted values, and the JSON-safe client type boundary.

Tailwind

Tailwind is added the same way as the JS integrations:

osmose add tailwindcss

The command installs tailwindcss and @tailwindcss/vite and records the plugin in the active environment. If you installed the packages by hand or the command warned that it could not save configuration, add this entry manually:

[[environments.dev.integrations]]
name = "tailwindcss"

Replace dev with your active environment's name. For Tailwind 4, create a CSS entry such as assets/theme.css:

@import "tailwindcss";

Load it from the layout with {% import "theme.css" %}. osmose add does not generate this file, insert CSS directives, or configure source scanning for you. Follow the installed Tailwind version's setup for source detection and any shadow-root styling needs.

Mixing frameworks

Separate custom-element tags can coexist on a page, and Lit/native elements can sit alongside .vue and .svelte entries. However, all effective integration plugins are loaded into the same Vite configuration with their default options. Osmose does not give React, Preact, and Solid separate JSX include/exclude rules. Installing multiple JSX frameworks may therefore make their transforms compete; per-file imports or TypeScript jsxImportSource alone are not a guarantee that the combined build is supported.

Use one JSX framework per theme unless you have verified the exact combination against a real build and browser mount. Per-framework examples and native typechecking are not proof of mixed-JSX compatibility. Production component entries reference shared dependencies through the theme's bundles/import map; this is not a promise that selecting a framework adds zero unused output.

Back to documentation contents

Deploying

Osmose compiles source into Shopify-native theme files. build validates or compiles locally, package writes a distributable directory, and push compiles and uploads. These are distinct operations: build does not automatically write dist/, and push should normally receive your source theme, not dist/.

Released builds require an activated Osmose beta license for these commands. Production asset compilation needs the project's Node/Vite/framework dependencies. Run commands from the configured source theme directory unless an override is shown below.

Build and package locally

These commands do not upload theme files:

osmose build
osmose package --output dist

build compiles the full production theme and reports the result. With a Shopify CLI backend it also creates a local mirror under .osmose/; GraphQL builds do not write that mirror. For a predictable output directory, use package. Its --output path is relative to the active theme's source directory unless absolute. It writes plain theme files, not a ZIP, and does not run Theme Check. Use a fresh output directory because packaging overwrites matching files without removing stale ones.

To run Shopify Theme Check on compiled output, install Shopify CLI first:

osmose build --check --fail-level error

Theme Check requires no Shopify store access. It uses a generated mirror, not your uncompiled Osmose source. Compilation may still write local artifacts. --profile additionally measures the already-remote theme, not this local build; profiling is skipped if Shopify sign-in or [settings.perf] budgets are absent.

You can hand the packaged dist/ theme to Shopify CLI directly. For example, after installing Shopify CLI and supplying your real store host and a disposable theme ID in SHOPIFY_FLAG_STORE and SHOPIFY_FLAG_THEME_ID (with CLI authentication or a Theme Access token configured), this uploads:

shopify theme push --path ./dist \
  --store "$SHOPIFY_FLAG_STORE" --theme "$SHOPIFY_FLAG_THEME_ID" --nodelete

Select a target and backend

In osmose.toml, the top-level active value selects an [environments.<name>] table containing storefront_url, theme_id, access_token, and directory. access_token may be empty with an appropriate Shopify sign-in. Use the store's your-store.myshopify.com host and a numeric theme ID. Change active in the file or use the TUI environment switcher; there is no global --env flag.

Backend selection is configured under [settings]:

[settings]
theme_backend = "auto"

Merge that key into an existing settings table rather than duplicating it. You can override it per invocation with --backend:

  • auto (default): GraphQL when the active environment has a usable token or Osmose-managed Shopify sign-in; otherwise Shopify CLI.
  • graphql: Direct Shopify API operations. No Shopify CLI dependency, but the build still needs its local asset tooling. Use a Theme Access password, an appropriately authorized Admin API token, or osmose auth login.
  • shopify-cli: Requires the shopify executable and access through its token or account flow. Osmose compiles a mirror and runs Theme Check before pushing. This is not the same session store as osmose auth login.

Theme reads and writes remain subject to Shopify permissions and API restrictions; an arbitrary Admin API token is not enough. Theme-file writes need authorized theme access (including write_themes for Admin API credentials). Storefront passwords are separate from API/Theme Access tokens.

Local push

After verifying the active environment targets the intended theme:

osmose push

This writes remote theme files and can update a live theme. There is no general confirmation step to rely on. Prefer an unpublished test theme.

The command builds production output and uploads through the selected backend. GraphQL sends the prepared files through batch upserts; it does not first use the dry-run plan to upload only changed files. Shopify CLI handles its own transfer. Normal push does not delete remote-only files; Shopify CLI is invoked with --nodelete.

Shopify CLI push uses a temporary mirror and cleans that mirror afterwards. Use --keep-artifacts to retain it, or --mirror-dir .osmose/review-theme for a persistent mirror. Diagnostics can remain under .osmose/.

Dry run

With the same store access required for remote reads:

osmose push --dry-run

This compiles locally, reads the remote theme, and prints create/update/delete/skip comparisons without uploading or deleting remote theme files. It is not offline and may write local artifacts. The Shopify CLI backend also runs Theme Check and downloads a local snapshot for comparison.

DELETE entries describe remote-only files in the comparison, not actions that a subsequent normal push will execute. Remote deletion requires a separate explicit command, such as the following destructive example after replacing the path:

osmose delete assets/obsolete-file.js --yes

Do not treat a dry-run result as a transaction or a guarantee of a later upload's success: the remote theme and access permissions can change.

Pull

osmose pull

This reads Shopify and writes into the active environment's local source directory. It can overwrite local files, so commit or back up your work first. Liquid files containing Osmose component/import directives are protected by default; --force explicitly permits replacing that authored source with remote output. Pull does not reconstruct components from compiled Liquid and is not the inverse of compilation.

Headless push

Supply real values through your CI secret/configuration system:

  • OSMOSE_LICENSE_KEY: an approved beta key if the runner is not activated.
  • SHOPIFY_FLAG_STORE: the store's your-store.myshopify.com host.
  • SHOPIFY_FLAG_THEME_ID: the target theme's numeric ID.
  • SHOPIFY_CLI_THEME_TOKEN: its Theme Access token.

With these set, from a theme source directory whose dependencies are installed:

osmose push --backend graphql --directory . --dry-run --json
# Remove --dry-run only when you intend to upload to the configured theme.

For push, --url, --theme, and --password take precedence over the corresponding environment variables in the headless target path. Both explicit --url and --theme, or both store/theme environment variables, activate that path. --directory is a push headless-mode flag; it defaults to . there. Otherwise push uses the active environment's source directory.

Headless push can run without osmose.toml; when present, config can still supply integrations and settings. These flags are not generic overrides for every config field. dev does not accept these store/directory flags and requires a configured active environment. A fresh CI runner cannot complete interactive Shopify sign-in; use a token rather than assuming a developer's cached login exists there.

Publish: choose the backend explicitly

publish does not mean the same thing for both backends:

  • GraphQL: Requires --name. Creates a theme, then compiles and pushes to it. --role defaults to unpublished; --role live can change the live storefront. Creation happens before the build/upload, so a later failure can leave the new theme behind.
  • Shopify CLI: Uses --name as the target theme selector, or falls back to configured theme_id. It pushes that target and runs shopify theme publish --force, making it live. It does not create a new unpublished preview theme, and --role unpublished does not make this path safe.

For a new unpublished theme using the configured store and authorized GraphQL access, replace the example name if desired:

osmose publish --backend graphql --name "Osmose preview" --role unpublished

This still creates and uploads a remote theme. Use package, not publish, when you only want local distributable files.

Back to documentation contents

Command reference

Common CLI entry points and their important constraints. Run osmose <command> --help for that command's flags. Most project/store commands in released builds require an activated Osmose beta license; see getting started.

Project

  • osmose new [directory] — Download Shopify's skeleton, overlay the starter, and install dependencies. Use --yes without a terminal, --pm npm (or pnpm, yarn, bun) to choose a package manager, --frameworks react,tailwindcss to choose integrations, and --skip-install to defer installation. --force permits a non-empty destination; scaffolding removes the target's .git directory, so do not use it to adopt an existing project.
  • osmose init — Write config for an existing theme. Requires --store and --theme (or their environment variables); --password supplies a token. --env names the new environment (default dev), --directory sets its source directory, and --skip-auth skips backend access setup. Existing config is rejected unless --force is supplied, which replaces it. --global writes global rather than project config.
  • osmose config — List existing environments/config location, or create a placeholder when there are no environments. Edit the file yourself to supply credentials or add environments; this command is not an interactive editor.
  • osmose add <integration> — Add and install a framework or Tailwind integration: lit, preact, react, vue, svelte, solid, or tailwindcss.

Development and local output

  • osmose dev — Start the configured environment's development loop, compile, and sync to Shopify immediately and on changes. Starts local WebSocket/Vite services and, for the Shopify CLI backend, shopify theme dev. This writes remote theme files. --debug also writes compiled files under the source theme's debug-output/. It has no store, directory, or --env override flags.
  • osmose build — Compile the full production theme without uploading. --directory selects a source theme/config; --check also runs Shopify Theme Check with --fail-level (default error). Theme Check needs Shopify CLI but not store access. This is not the command that writes dist/; use package.
  • osmose build <file> [files...] — Compile individual files and print labelled output to stdout. File arguments cannot be combined with --check or JSON output; this is not a full production asset build.
  • osmose package --output dist — Compile production files into a directory (default dist, relative to the active source theme). No ZIP is created, no remote upload occurs, and no Theme Check is run. Use a fresh output directory: existing files are overwritten but stale files are not cleared.
  • osmose lsp --stdio — Run the Liquid language server for an editor client.
  • osmose doctor — Check local dependencies, theme structure, and configuration. --directory overrides the theme location; --remote adds a Shopify access check without uploading theme files. Errors produce a nonzero exit status.
  • osmose deps — Compare package.json with the packages this version requires: shared build tooling plus each configured or detected integration's packages. Missing packages are listed with the exact install command and produce a nonzero exit status. --install records and installs them with the project's package manager; --json prints the report as one object.

Build/package can write generated local artifacts even though they do not write to Shopify. Shopify CLI builds use a mirror under .osmose/; GraphQL builds do not emit that mirror. Use package when you need a predictable output directory.

Store operations

  • osmose push — Compile production files and upload to the selected theme. --dry-run instead reads remote files and prints a comparison. --theme, --url, --password, and --directory support a headless target; see deploying for their precedence and safety limitations.
  • osmose pull — Download remote theme files into the configured source directory. This can overwrite local files. Local Liquid containing Osmose component/import directives is protected unless --force is supplied. It is not a source-code decompiler or a guaranteed backup operation.
  • osmose delete <theme-file> [theme-file...] --yes — Delete explicitly named remote files. --theme overrides the target theme ID.
  • osmose publish — Backend-dependent and potentially live-store-changing. GraphQL requires --name and creates a theme before pushing; --role defaults to unpublished. Shopify CLI pushes the target named by --name or configured theme_id, then publishes it live with force. See deploying.

push and publish use temporary Shopify CLI mirrors by default. Their --keep-artifacts flag retains the temporary mirror; --mirror-dir chooses a persistent mirror under .osmose.

Profiling

  • osmose profile — Request Shopify's Liquid render profile for / on the active environment's remote theme. Requires osmose auth login; a Theme Access token alone cannot profile. It does not upload your current source.
  • --url /products/your-handle selects a real storefront path; without --all, only the first supplied URL is used. --theme overrides the preview theme.
  • --samples 3 selects repeated measurements (maximum 10); the report uses one median-total request, choosing the lower median for an even sample count. --top 0 shows all files, --json prints JSON, and --raw profile.json writes a raw speedscope report for a single-page profile.
  • osmose profile --all profiles routable local templates against the store, reports a section-by-template matrix, and caches it at .osmose/reports/profile-all.json. Templates without a generic route may be skipped; --url supplies extra paths.
  • osmose build --check --profile runs Theme Check, then profiles the remote storefront against [settings.perf] budgets. Profiling is skipped when budgets or an Osmose Shopify sign-in are missing; a successful build alone is not proof that profiling ran. This does not push the just-built theme.

License and Shopify sign-in

  • osmose login — Activate the machine with an Osmose beta key, supplied interactively, through OSMOSE_LICENSE_KEY, or with --key.
  • osmose logout — Remove the machine's Osmose license activation.
  • osmose auth login — Sign in to Shopify via device flow. --no-browser prints the link instead of opening a browser.
  • osmose auth status — Show Shopify sign-in state and cached store access.
  • osmose auth logout — Forget the machine's Osmose-managed Shopify sign-in.

Maintenance

  • osmose update — Check for updates, review notes, and confirm installation. Inside a project, the update then runs osmose deps --install with the new binary so packages the new version requires are recorded and installed. When already current, the same reconciliation runs in place.
  • osmose update --notes — Read the latest release's notes without downloading a binary or installing. This still contacts the release service.
  • osmose update --force — Download and replace the executable without confirmation. The updater does not verify a checksum or release signature.
  • osmose version (or osmose --version) — Print the installed version.
  • osmose check-term — Report terminal suitability; --try-resize attempts resizing and --help-resize prints guidance.
  • osmose bug --print — Print the issue-tracker URL without opening a browser. --include-context also prints diagnostic context for review before sharing.
  • osmose release verify — Maintainer check of local release metadata and public release endpoints; it does not publish or install a release. Use --root for the tooling release directory containing VERSION, optionally --version for the expected version. --get-base and --cdn-base override endpoint bases; --legacy=false skips the legacy /releases URL check.

Shared flags and CI inputs

--backend auto|graphql|shopify-cli is inherited by subcommands that use a theme backend. auto prefers GraphQL when the active environment has a usable token or Osmose Shopify sign-in; otherwise it selects Shopify CLI.

There is no global --env or --debug flag. Select a configured environment with top-level active in osmose.toml or the TUI. init --env names an environment being created; dev --debug is specific to development.

build (without file arguments), package, push, pull, and doctor support --json or OSMOSE_JSON=1. profile --json has its own report format. For supported store commands, SHOPIFY_FLAG_STORE, SHOPIFY_FLAG_THEME_ID, and SHOPIFY_CLI_THEME_TOKEN provide store inputs. These are not generic overrides for every osmose.toml setting or every command.

The TUI

Running osmose with no arguments opens the interactive dashboard, with development, sync, environment switching, diagnostics, and release-note views. The palette exposes TUI actions; it is not a wrapper for every CLI subcommand.

Release notes

Press r on the TUI home screen or choose release notes in the command palette to read the embedded history offline. Unreleased work is labelled separately from published versions. Use arrow keys or j/k to scroll, Page Up / Page Down to move by a page, Home / End to jump, and Escape to close.

Press u to check for an update and review the target release before deciding whether to install. Reading notes never installs an update. From the CLI:

osmose update --notes

An interactive osmose update opens the notes and asks for confirmation. When output is redirected, it prints plain-text notes and a [y/N] prompt.

Back to documentation contents