Skip to content

Luna: Frontend Setup

Introduction

Luna is framework-agnostic: the same PHP adapter and core runtime power React, Vue 3, and Svelte apps. This chapter covers the client-side setup for each — booting the app, resolving page components, persistent layouts, managing the document <head>, code splitting, the Vite plugin, progress indicators, asset versioning, and server-side rendering.

Read the installation section of the introduction first for package names.

Creating the App

Every Luna frontend boots through createLunaApp. It takes a resolve callback (name → component) and a setup callback (mount the app). The three adapters differ only in how they mount.

React

jsx
// resources/js/app.jsx
import { createLunaApp } from '@laraxgram/react'
import { createRoot } from 'react-dom/client'

createLunaApp({
    resolve: (name) => {
        const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true })
        return pages[`./Pages/${name}.jsx`]
    },
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />)
    },
})

Vue 3

js
// resources/js/app.js
import { createLunaApp } from '@laraxgram/vue3'
import { createApp, h } from 'vue'

createLunaApp({
    resolve: (name) => {
        const pages = import.meta.glob('./Pages/**/*.vue', { eager: true })
        return pages[`./Pages/${name}.vue`]
    },
    setup({ el, App, props, plugin }) {
        createApp({ render: () => h(App, props) })
            .use(plugin)
            .mount(el)
    },
})

Svelte

js
// resources/js/app.js
import { createLunaApp } from '@laraxgram/svelte'
import { mount } from 'svelte'

createLunaApp({
    resolve: (name) => {
        const pages = import.meta.glob('./Pages/**/*.svelte', { eager: true })
        return pages[`./Pages/${name}.svelte`]
    },
    setup({ el, App, props }) {
        mount(App, { target: el, props })
    },
})

App Options

createLunaApp accepts more than resolve and setup:

OptionDescription
resolveMaps a component name to its module (required).
setupMounts the resolved App into el (required).
idThe root element id (default app).
titleA callback (title, page) => string to template every page title — e.g. append · MyApp.
progressProgress bar options, or false to disable.
pageA pre-resolved page object (used in SSR).
layoutA default layout resolver applied to every page.

Code Splitting

The import.meta.glob above with { eager: true } bundles all pages together. Drop eager (and make resolve async) to split each page into its own chunk, loaded on demand:

js
resolve: (name) => {
    const pages = import.meta.glob('./Pages/**/*.jsx')
    return pages[`./Pages/${name}.jsx`]()
},

The Vite Plugin

The @laraxgram/vite plugin removes boilerplate and powers dev-time SSR. Register it in vite.config.js alongside your framework plugin:

js
import luna from '@laraxgram/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default defineConfig({
    plugins: [react(), luna()],
})

Pages Shorthand

Instead of writing import.meta.glob in every project, pass a pages directory to createLunaApp and let the plugin generate the resolver for you:

js
createLunaApp({
    pages: './Pages',
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />)
    },
})

The plugin detects your adapter (Vue/React/Svelte) from your imports and transforms pages into the correct glob. Add support for other frameworks with the plugin's frameworks option.

Persistent Layouts

By default each Luna visit swaps the entire page component, which unmounts and remounts your layout — resetting its state (scroll position, open menus, media playback). A persistent layout stays mounted across visits. Assign one by setting layout on the page component:

jsx
// resources/js/Pages/Dashboard.jsx
import AppLayout from '@/Layouts/AppLayout'

function Dashboard({ stats }) {
    return <StatsGrid stats={stats} />
}

Dashboard.layout = (page) => <AppLayout>{page}</AppLayout>

export default Dashboard

Nest multiple layouts by returning an array, and set a default layout for every page via the layout option on createLunaApp. The setLayoutProps / resetLayoutProps helpers let you pass data into a persistent layout from the active page. Vue and Svelte use their own idiomatic layout conventions exported from the adapter.

Managing the Head

Use the <Head> component to set the document title and inject <meta> / <link> tags per page:

jsx
import { Head } from '@laraxgram/react'

<Head title="Users">
    <meta name="description" content="Manage your users" />
</Head>

Set a global title template with the title option on createLunaApp so every <Head title> gets a suffix:

js
createLunaApp({
    title: (title) => (title ? `${title} · MyApp` : 'MyApp'),
    // ...
})

For server-side rendering to emit these tags into the initial HTML, keep the @lunaHead directive in your root template. Vue and Svelte export the same <Head> component.

Progress Indicator

Luna ships a top-of-page loading bar that appears during visits taking longer than a short delay. It's enabled by default; configure or disable it via the progress option:

js
createLunaApp({
    progress: {
        color: '#29d',
        delay: 250,
        showSpinner: false,
    },
    // progress: false, // to disable entirely
})

For custom indicators, subscribe to the router's progress events.

Asset Versioning

When you deploy new frontend assets, browsers with the old bundle cached could render a stale page against new server props. Luna guards against this with an asset version: each response carries a version string, and when the client detects a mismatch on a visit, it performs a full page reload to fetch the new assets.

Set the version from a service provider — typically a hash of your manifest:

php
use LaraGram\Luna\Luna;

Luna::version(fn () => md5_file(public_path('build/manifest.json')));

The Vite integration wires a sensible default, so most apps never set this manually.

Server-Side Rendering

Server-side rendering (SSR) pre-renders the initial page to HTML on the server, improving first-paint and making pages crawlable. Luna renders through a small Node (or Bun) service.

The SSR Entry Point

Create resources/js/ssr.jsx (auto-detected by the Vite plugin) that renders the page to a string:

jsx
// resources/js/ssr.jsx
import { createLunaApp } from '@laraxgram/react'
import createServer from '@laraxgram/react/server'
import { renderToString } from 'react-dom/server'

createServer((page) =>
    createLunaApp({
        page,
        render: renderToString,
        resolve: (name) => {
            const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true })
            return pages[`./Pages/${name}.jsx`]
        },
        setup: ({ App, props }) => <App {...props} />,
    }),
)

Each adapter exports its own server entry (@laraxgram/react/server, @laraxgram/vue3/server, @laraxgram/svelte/server).

Configuration & Commands

Enable and configure SSR in config/luna.php under the ssr key — the runtime (node/bun), the render service url, the bundle path, and error handling. In production, build the SSR bundle and run the service:

shell
# Build the client and SSR bundles.
npm run build

# Manage the SSR render service.
php laragram luna:start-ssr
php laragram luna:stop-ssr
php laragram luna:check-ssr

During development the Vite plugin runs an in-process SSR endpoint, so you don't need a separate service — just npm run dev. When SSR rendering fails, Luna gracefully falls back to client-side rendering; set ssr.throw_on_error to fail loudly in E2E tests, or listen for the SsrRenderFailed event.

NOTE

Telegram Mini Apps can use SSR too, but because they only ever run inside the authenticated Telegram client, first-paint SEO is irrelevant — most TMAs leave SSR off. See Telegram Mini Apps.

Next Steps

Your SPA is fully configured. To build a Telegram Mini App on top of it, continue to Telegram Mini Apps.

Released under the MIT License.