Optional Features

Step 12 -- Optional: Set Up a React/Inertia Site

Public pages render via Blade/Livewire by default. The package also ships an optional Inertia/React rendering mode for public pages -- the admin panel stays Blade/Livewire either way; this only changes how visitors see public pages rendered. Skip this step entirely for a Blade/Livewire-only site.

Prerequisites

An Inertia + React frontend. Jetstream's own "Jetstream with Inertia" option in Step 3 installs a Vue-based Inertia stack (@inertiajs/vue3) -- it is not a path to React, and choosing it does not help here. Instead:

  • New app: start with Laravel's official React starter kit (laravel new your-app --react, or composer create-project laravel/react-starter-kit) before adding this package, then use the "Vanilla - no Jetstream" option in Step 3 -- the starter kit's own auth scaffolding replaces Jetstream's.
  • Existing app: add Inertia/React by hand -- @inertiajs/react, react, react-dom, and @vitejs/plugin-react in package.json, inertiajs/inertia-laravel via Composer, and the React plugin wired into vite.config.js. Follow Inertia's own React setup guide for the exact steps -- there's no artisan command that does this the way jetstream:install does for Vue.
  • laravel/fortify -- used for a role-aware post-login redirect; already present via either path above (the React starter kit depends on it directly; Jetstream, if installed for its Livewire/API/teams support, pulls it in too).

laravel/jetstream itself is always present regardless -- it's a hard Composer dependency of this package (its default User model uses Jetstream's HasProfilePhoto trait), independent of whether you ever run jetstream:install.

Need team management? The React starter kit's auth scaffolding is single-user only -- no team switching, no invitations. Jetstream's Vue stack is the only ready-made implementation of that feature in this ecosystem; there's no React version of it. If your React site needs teams, either run php artisan jetstream:install inertia --teams and port its team-switching UI to React by hand, or build teams yourself. There's no shortcut here -- factor this in before committing to a React frontend if teams are a hard requirement.

Install

php artisan textstem:install-react

This publishes the React source under resources/js/ -- the rendering engine, a Prose component and default page template matching the package's one bundled Blade component and default template, the page shell, and TypeScript types -- and, if you confirm, appends TEXTSTEM_REACT_RENDERING=true to your .env.

Two things it can't do for you, since they touch files you own and customize:

  1. In resources/js/app.tsx, route pages named textstem/* to the published layout:

    import PublicLayout from '@/layouts/public-layout';
    // ...
    layout: (name) => {
        if (name.startsWith('textstem/')) {
            return PublicLayout;
        }
        // ... your existing cases
    },
    
  2. In your own resources/js/types/global.d.ts, add the textstem prop to your sharedPageProps declaration (this can't be a drop-in augmentation file -- TypeScript doesn't merge two separate inline shapes assigned to the same property across files):

    import type { TextstemSharedProps } from '@/types/textstem-shared';
    // ...
    declare module '@inertiajs/core' {
        export interface InertiaConfig {
            sharedPageProps: {
                // ... your existing keys
                textstem: TextstemSharedProps;
                [key: string]: unknown;
            };
        }
    }
    

Then confirm @inertiajs/react, react, and react-dom are in package.json (see Prerequisites above) and build:

npm run build
# or, for development:
npm run dev

What changes

Concern Blade/Livewire (default) React/Inertia (react_rendering: true)
Public page rendering WranglerPageController ReactPageController
Admin panel Blade/Livewire Unchanged -- Blade/Livewire
Post-login redirect config('fortify.home') for everyone Role-aware: the dashboard route for a user who can access-textstem-admin, / for everyone else
Admin bar (edit-this-page overlay) <x-textstem::adminbar/>, only reflects the page you loaded, not later client-side navigation React AdminBar, driven by a shared Inertia prop -- stays correct across client-side navigation
Page-props cache The package's own PageCache (raw HTML) A separate cache keyed on path + query, reusing the same textstemapp.cache.* config

Adding your own components and page templates

Add components and templates the same way you would for Blade: create the PHP class (App\Wrangler\Components\{Name}), then add a matching React component (resources/js/wrangler/components/{Name}.tsx) and register it in components/index.ts. ActiveComponent::build() (not render()) is what the React path calls -- a component that doesn't override it already gets a safe default (its raw config as data), the same as it does today. See docs/react-rendering.md for full details, and docs/Notes-wranglercomponents.md for the PHP side.

Configuration

config/textstemapp.php:

'react_rendering' => env('TEXTSTEM_REACT_RENDERING', false),

Step 13 -- Optional: Live Page Screenshots

The Pages admin's Cards and Columns views illustrate each page. Without any extra setup a page shows its Page image (set on the editor's SEO tab), else the first image in its content, else a plain page icon. With live screenshots turned on, each page shows a picture of how it actually looks, refreshed in the background whenever it or one of its blocks is saved.

Screenshots are optional and fail safe: while they are turned off, the tools are missing, or captures keep failing (3 in a row pause them for an hour), nothing is queued and the cards use the pictures above. Editors never wait on a capture.

How it works

  • Saving a page, or adding, changing or removing one of its blocks, queues a CapturePageScreenshot job a few seconds later (one waiting job per page, so a burst of edits takes one screenshot). Opening the Cards or Columns view also queues pages that have no screenshot yet or an out-of-date one.
  • The job loads the page in headless Chrome through a short-lived signed link (valid for 5 minutes, no login), so drafts and private pages are captured as staff see them. Only a page that loads with HTTP 200 at its own address counts; error pages, login screens and redirects fail the capture instead of being saved.
  • Images are 640 x 400 WebP (about 20-40 KB), stored on a private disk (storage/app/page-screenshots/{page-id}.webp by default) and served only to signed-in staff who can view the page. A page's screenshot is removed when the page is permanently deleted.
  • Captures are never taken inside a save: if the queue runs jobs immediately (QUEUE_CONNECTION=sync), saves queue nothing and you capture with the command below.

Install

In the host app:

composer require spatie/browsershot
npm install puppeteer

Puppeteer normally downloads Chrome (~170 MB) on every npm install. To keep that out of builds that don't need it, add a .puppeteerrc.cjs to the app root that makes the download opt-in:

// .puppeteerrc.cjs
module.exports = {
    skipDownload: process.env.PUPPETEER_DOWNLOAD_CHROME !== 'true',
};

Then install the browser Browsershot uses, once per server, as the user your queue worker runs as (it goes into that user's ~/.cache/puppeteer and survives deploys):

npx puppeteer browsers install chrome-headless-shell

On Linux, Chrome also needs system libraries and fonts. On Ubuntu:

sudo apt-get install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 \
  libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2t64 \
  fonts-liberation fonts-noto-color-emoji

(On Ubuntu 22.04 use libasound2 instead of libasound2t64.)

Configure

TEXTSTEM_PAGE_SCREENSHOTS=true

Optional settings (see page_screenshots in config/textstemapp.php and ENV_REFERENCE.md):

# Full paths, when node/npm/Chrome aren't on the queue worker's PATH
TEXTSTEM_SCREENSHOT_NODE=/usr/bin/node
TEXTSTEM_SCREENSHOT_NPM=/usr/bin/npm
TEXTSTEM_SCREENSHOT_CHROME=
# Where the browser reaches this site, when not APP_URL (e.g. behind a proxy)
TEXTSTEM_SCREENSHOT_BASE_URL=
# Self-signed certificates, e.g. Laravel Herd's .test domains
TEXTSTEM_SCREENSHOT_IGNORE_HTTPS_ERRORS=false
# Chrome running as root, e.g. in Docker
TEXTSTEM_SCREENSHOT_NO_SANDBOX=false
TEXTSTEM_SCREENSHOT_QUEUE=default
TEXTSTEM_SCREENSHOT_DISK=local

A queue worker must be running (Step 8). Turn screenshots off in your test suite so tests never launch Chrome, e.g. in phpunit.xml:

<env name="TEXTSTEM_PAGE_SCREENSHOTS" value="false"/>

Check and capture

php artisan textstem:page-screenshots --check   # test the setup with a throwaway screenshot
php artisan textstem:page-screenshots --all     # capture every page now
php artisan textstem:page-screenshots --page=17 # capture one page (add --queue to queue instead)
php artisan queue:restart                       # so running workers pick up the new settings

--check names the fix for the common problems (missing puppeteer, Chrome, system libraries, sandbox or Node). When screenshots are turned on but not working, the dashboard's Site Health widget and textstem:health-check show a warning with the reason.

Laravel Forge

  1. Add npm ci --omit=dev to the site's deploy script, after composer install (puppeteer is a runtime dependency, so --omit=dev is enough).
  2. Over SSH as forge, in the site folder: run npm ci --omit=dev, then npx puppeteer browsers install chrome-headless-shell, and install the system libraries above.
  3. Set TEXTSTEM_PAGE_SCREENSHOTS=true in the site's environment, deploy, then run the check and capture commands above.

Local development with Laravel Herd

Herd's Node isn't on the queue worker's PATH, and its .test certificates are self-signed:

TEXTSTEM_PAGE_SCREENSHOTS=true
TEXTSTEM_SCREENSHOT_NODE="/Users/you/Library/Application Support/Herd/config/nvm/versions/node/v22.x.x/bin/node"
TEXTSTEM_SCREENSHOT_NPM="/Users/you/Library/Application Support/Herd/config/nvm/versions/node/v22.x.x/bin/npm"
TEXTSTEM_SCREENSHOT_IGNORE_HTTPS_ERRORS=true

(which node in a terminal shows the path.)

Troubleshooting

--check or Site Health says Fix
Cannot find module 'puppeteer' Run npm ci (or npm install) in the app folder, and add it to the deploy script.
Could not find Chrome / chrome-headless-shell Run npx puppeteer browsers install chrome-headless-shell as the queue worker's user.
error while loading shared libraries Install the system libraries above. To see which are still missing: ldd ~/.cache/puppeteer/chrome-headless-shell/linux-*/chrome-headless-shell-linux64/chrome-headless-shell | grep 'not found'
Running as root without --no-sandbox Run the worker as a normal user, or set TEXTSTEM_SCREENSHOT_NO_SANDBOX=true.
No usable sandbox! Ubuntu 23.10 and later block Chrome's sandbox with AppArmor, even for a normal user. Add an AppArmor profile allowing user namespaces for Puppeteer's chrome and chrome-headless-shell binaries (see Deploying on Forge), or set TEXTSTEM_SCREENSHOT_NO_SANDBOX=true.
The page answered 403 / 404 / 401 or redirected to ... The server can't load its own pages at APP_URL: check APP_URL, any password protection on the site, or set TEXTSTEM_SCREENSHOT_BASE_URL.
Page screenshots are paused after 3 failed attempts Fix the cause shown, then --check (a passing check resumes them).

Memory: each capture uses roughly 150-300 MB while it runs. On small servers, give screenshots their own queue (TEXTSTEM_SCREENSHOT_QUEUE=screenshots) with a single worker so they never compete with mail or other jobs.

Configuration

config/textstemapp.php (all keys have defaults):

'page_screenshots' => [
    'enabled' => env('TEXTSTEM_PAGE_SCREENSHOTS', false),
    'node_binary' => env('TEXTSTEM_SCREENSHOT_NODE'),
    'npm_binary' => env('TEXTSTEM_SCREENSHOT_NPM'),
    'chrome_path' => env('TEXTSTEM_SCREENSHOT_CHROME'),
    'base_url' => env('TEXTSTEM_SCREENSHOT_BASE_URL'),
    'width' => 1280,          // browser window; the image is half this size
    'height' => 800,
    'quality' => 80,
    'disk' => env('TEXTSTEM_SCREENSHOT_DISK', 'local'),
    'directory' => 'page-screenshots',
    'queue' => env('TEXTSTEM_SCREENSHOT_QUEUE', 'default'),
    'timeout' => 60,
    'delay' => 15,            // seconds after an edit before capturing
    'ignore_https_errors' => env('TEXTSTEM_SCREENSHOT_IGNORE_HTTPS_ERRORS', false),
    'no_sandbox' => env('TEXTSTEM_SCREENSHOT_NO_SANDBOX', false),
],

To capture another way (for example a hosted screenshot service), bind your own implementation of Medialight\Textstem\Domain\Pages\Screenshots\ScreenshotCapturer in a service provider; the package only binds its Browsershot capturer if nothing else is bound.


Step 14 -- Optional: Form Data

Form Data lets editors build forms in the admin (questions, steps, show-if rules, file uploads), publish them, and place them on pages with the Active Form page block. Answers are kept in the admin, can be emailed to staff, exported as CSV and posted to a webhook.

It is off until a site turns it on. While off, none of its tables, routes, menu, page block, dashboard widget or schedule is loaded.

The same steps, with troubleshooting, are in Enabling Active Forms.

Turning it on

FORMS_ENABLED=true
php artisan migrate

The migration creates textstem_forms, textstem_form_versions, textstem_form_submissions and textstem_form_submission_files, plus the Form Data permissions and a forms-manager role. If the config is cached, run php artisan config:cache again after changing .env.

The tables are prefixed so they never meet the forms / form_data tables of the older textstem-forms package; Form Data does not read or change those.

Who can use it

Permission Allows
list forms Seeing forms, their questions and settings
manage forms Creating, editing, publishing, deleting and restoring forms
view form submissions Reading answers, marking them read, archived or spam, downloading files and CSV
delete form submissions Deleting answers

The forms-manager role has all four and can enter the admin. Super admins pass every check.

What the site needs

  • Livewire on public pages. The form on a page is a Livewire component, so the site's page layout must load Livewire's scripts (Livewire 3 injects them unless that's been turned off).

  • Tailwind classes for the public form. Its views use Tailwind utility classes and expect the @tailwindcss/forms plugin. Add the package's public views to the site's Tailwind content and rebuild the CSS:

    content: [
        // ...
        './vendor/medialight/textstem-laravel/resources/views/forms/**/*.blade.php',
        './vendor/medialight/textstem-laravel/resources/views/livewire/forms/public-form.blade.php',
        './vendor/medialight/textstem-laravel/resources/views/wrangler/components/active-form/*.blade.php',
    ],
    
  • No older ActiveForm block in the app. An App\Wrangler\Components\ActiveForm class, or views in resources/views/wrangler/components/active-form/, take the package block's place. Sites that used the old textstem-forms package may have them; remove them unless you mean to override the block.

For particular features

Feature Needs
Emails to staff, a copy for the sender A working mailer. Emails and webhooks are queued, so run a queue worker (Step 8) unless QUEUE_CONNECTION=sync.
Removing old and deleted answers The scheduler (Step 9): forms:prune-submissions runs daily at 03:45.
File uploads A private, writable disk (FORMS_UPLOAD_DISK, default local); shared storage such as S3 on more than one server. Livewire's temporary uploads must work.
Robot check TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY, with the site's hostnames added to the key in Cloudflare. Copies of the answers are only sent from forms with the check on.
Forms for signed-in visitors only FORMS_VISITOR_GUARD (an auth guard) and FORMS_VISITOR_LOGIN_ROUTE, or your own FormSubmitters binding (below).
Editors' draft preview in the site's look A Blade component taking a page prop, named by FORMS_PREVIEW_LAYOUT (default public-layout). Without it the preview is a plain page.
Webhooks Outbound HTTPS. Private and reserved addresses are refused unless FORMS_WEBHOOKS_ALLOW_PRIVATE_HOSTS=true.
Rate limits and IP hashes behind a proxy Trusted proxies set up, so each visitor's own address is seen.

All the variables are listed in ENV_REFERENCE.md.

Signed-in visitors

Forms can be limited to signed-in visitors. By default the visitor is whoever is signed in on FORMS_VISITOR_GUARD, and guests are sent to FORMS_VISITOR_LOGIN_ROUTE and back. To decide this yourself (for example to show staff a link to the visitor's record on each answer), bind your own implementation in a service provider's register():

use Medialight\Textstem\Domain\Forms\Contracts\FormSubmitters;

$this->app->singleton(FormSubmitters::class, MyFormSubmitters::class);

Extending Medialight\Textstem\Domain\Forms\Support\GuardSubmitters and overriding describe() is usually enough.

Customising the look

  • Site CSS: every part of a form carries a stable hook class beside its Tailwind utilities, so a stylesheet can restyle forms without copying views. The block is ts-form-block (__inner, __title, __intro, __form); the form is ts-form (__step, __errors, __section, __section-title, __grid, __captcha, __actions, __back, __submit); each question is ts-field ts-field--<type> ts-field--full|half, plus ts-field--required and ts-field--invalid, with ts-field__label, __required, __input, __options, __option, __option-input, __option-label, __help, __error, __limits, __uploading, __files, __heading and __text inside it; the thank-you and closed boxes are ts-form-message (--sent, --unavailable, __title, __body, __reference, __button). A rule like .ts-field__input { … } (0,1,0) ties with a utility, so scope it (.ts-form .ts-field__input) to win. Copied views should keep these classes.
  • The page block: add resources/views/wrangler/components/active-form/default.blade.php (and preview.blade.php for the page builder).
  • Any other Form Data view: copy it to resources/views/vendor/textstem/..., e.g. resources/views/vendor/textstem/forms/fields/short-text.blade.php.
  • Field types: textstemapp.forms.field_types maps each type name to a class implementing Medialight\Textstem\Domain\Forms\Fields\FieldType. Add your own there; don't rename or remove a type published forms use.

Checking it works

  1. Sign in as a user with forms-manager (or a super admin) and open Form Data → Forms.
  2. Create a form, add a question, Publish, and set its status to Open on Settings.
  3. Add an Active Form block to a page, choose the form, and fill it in on the site.
  4. The answer appears under the form's Answers tab.
esc