Skip to content Docs index for AI agents (llms.txt)
LaRecipe
3.x

Versions

CustomizeCustom React components

Custom React components

Write your own components in React, use them in Markdown like the built-ins, and give each one a no-JavaScript fallback and a Markdown form for agents.

Updated 15 min read

When the built-in components are not enough (a newsletter form, a pricing table, a live status badge), write your own in React and use it in Markdown like any built-in: <Subscribe list="larecipe" />. A component has three faces, and you write all three:

  • React, for readers whose browser runs JavaScript.
  • HTML, a server fallback for readers without JavaScript, for crawlers, and for the first paint.
  • Markdown, what agents get when they read the page as .md or through MCP.

The newsletter form at the end of the Introduction is one. This page builds it in four steps, straight from this site's code:

  1. Write the React component.
  2. Register it in the extension entry.
  3. Define it in PHP, with its fallback and its Markdown form.
  4. Build it with Vite.

Then use it in a page, or on every page.

Every file the Subscribe form needs
  • app/
    • Http/Controllers/SubscribeController.php
    • Providers/AppServiceProvider.php
  • resources/
    • larecipe/
      • extension.tsx
      • Subscribe.tsx
      • subscribe.css
    • views/
      • docs/subscribe.blade.php
      • newsletter.blade.php
  • routes/web.php
  • vite.config.js

The React component

A custom component is an ordinary React component. Its props are the tag's attributes, and Markdown between an opening and a closing tag arrives as children. Everything it imports from @larecipe/ui is provided by LaRecipe at runtime.

resources/larecipe/Subscribe.tsx
import { useState, type FormEvent } from 'react'
import { Button, Icon, Input, request, useLaRecipe } from '@larecipe/ui'

/** The form's words in each language the site serves (lang/{en,ar}/newsletter.php on the server). */
const STRINGS = {
  en: {
    label: 'Newsletter',
    title: 'Stay in the loop',
    body: 'LaRecipe release notes by email. Unsubscribe any time.',
    email: 'Email address',
    submit: 'Subscribe',
    sending: 'Subscribing',
    doneTitle: 'You are on the list',
    doneBody: (email: string) => `The next release note goes to ${email}.`,
    failed: 'That did not work. Please try again.',
    note: 'This form is a custom React component this site adds to LaRecipe.',
    noteLink: 'See how it is built',
  },
  ar: {
    label: 'النشرة البريدية',
    title: 'ابقَ على اطلاع',
    body: 'ملاحظات إصدارات LaRecipe عبر البريد الإلكتروني. يمكنك إلغاء الاشتراك في أي وقت.',
    email: 'عنوان البريد الإلكتروني',
    submit: 'اشترك',
    sending: 'جارٍ الاشتراك',
    doneTitle: 'أنت الآن في القائمة',
    doneBody: (email: string) => `ستصل ملاحظة الإصدار التالي إلى ${email}.`,
    failed: 'لم ينجح ذلك. حاول مرة أخرى.',
    note: 'هذا النموذج مكوّن React مخصّص يضيفه هذا الموقع إلى LaRecipe.',
    noteLink: 'شاهد كيف بُني',
  },
}

/** Where "See how it is built" goes: the custom components guide, in the reader's language and version. */
function guideUrl(base: string, locale: string, version: string): string {
  return `${base.replace(/\/$/, '')}${locale === 'ar' ? '/ar' : ''}/${version}/customize/custom-components`
}

/**
 * The newsletter form on every docs page (the article.after insert point) and
 * inline on the Introduction (<Subscribe />).
 *
 * React comes from the page's import map, so this shares LaRecipe's React.
 * useLaRecipe() gives the page and its locale; request() posts to our own
 * route with the session cookie and the XSRF token, and hands back Laravel's
 * validation errors. The markup matches the server fallback in
 * resources/views/docs/subscribe.blade.php.
 */
export function Subscribe({ list = 'larecipe', showcase = false }: { list?: string; showcase?: boolean }) {
  const { page, locale, boot, version } = useLaRecipe()
  const lang = locale.code === 'ar' ? 'ar' : 'en'
  const t = STRINGS[lang]
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState<'idle' | 'sending' | 'done'>('idle')
  const [error, setError] = useState<string | null>(null)

  async function submit(event: FormEvent) {
    event.preventDefault()
    setStatus('sending')
    setError(null)

    const response = await request<{ subscribed: boolean }>('/newsletter/subscribe', {
      method: 'POST',
      body: { email, list, source: page.path, locale: lang },
    })

    if (response.ok) {
      setStatus('done')
      return
    }

    setError(response.errors?.email?.[0] ?? response.message ?? t.failed)
    setStatus('idle')
  }

  const note = showcase ? (
    <p className="lr-subscribe-note">
      {t.note} <a href={guideUrl(boot.urls.base, lang, version)}>{t.noteLink}</a>
    </p>
  ) : null

  const head = (title: string, body: string) => (
    <div className="lr-subscribe-head">
      <span className="lr-subscribe-icon" aria-hidden="true">
        <Icon name="Mail" size={18} />
      </span>
      <div className="lr-subscribe-copy">
        <p className="lr-subscribe-label">{t.label}</p>
        <p className="lr-subscribe-title">{title}</p>
        <p className="lr-subscribe-body">{body}</p>
      </div>
    </div>
  )

  if (status === 'done') {
    return (
      <div className="lr-subscribe-wrap">
        <div className="lr-subscribe" data-lr-subscribe="react" role="status">
          {head(t.doneTitle, t.doneBody(email))}
        </div>
        {note}
      </div>
    )
  }

  return (
    <div className="lr-subscribe-wrap">
      <form className="lr-subscribe" data-lr-subscribe="react" onSubmit={submit}>
        {head(t.title, t.body)}
        <div className="lr-subscribe-row">
          <label className="lr-subscribe-field">
            <span className="lr-sr">{t.email}</span>
            <Input
              type="email"
              name="email"
              required
              autoComplete="email"
              placeholder="you@example.com"
              dir="ltr"
              value={email}
              aria-invalid={error ? true : undefined}
              onChange={(event) => setEmail(event.target.value)}
            />
          </label>
          <Button type="submit" variant="primary" loading={status === 'sending'}>
            {status === 'sending' ? t.sending : t.submit}
          </Button>
        </div>
        {error && (
          <p className="lr-subscribe-error" role="alert">
            {error}
          </p>
        )}
      </form>
      {note}
    </div>
  )
}
What @larecipe/ui gives your component
Export What it does
useLaRecipe() The current page (path, title, URLs, version, locale and meta), the set (key, name, versions, locales), the locale (code and direction), the theme (mode and the reader's preference), the signed-in user when you share one, t for LaRecipe's own UI strings in the page's language, and a navigate(url) helper.
request(path, options) A fetch to your app's own routes. It refuses other origins, sends the session cookie, Laravel's XSRF-TOKEN as X-XSRF-TOKEN and Accept: application/json, encodes body as JSON, and returns { ok: true, status, data } or { ok: false, status, data, errors, message }, with Laravel's 422 validation errors in errors.
Button, LinkButton, Input, Textarea, Field, Callout, Badge, Card, Kbd, Loader, Ticket, Perforation The parts the built-in UI is made of, in both themes and right to left. Button takes a variant (primary, secondary, outline, ghost, ink), a size and loading.
Icons, Icon The UI's own icon set.
Slot, useSlotDefault() Replace a built-in region of the page and still render the original. See insert points and slots.
defineExtension() Registers your components, insert points and slot overrides with the page.
Share the signed-in user and page values

The signed-in user is shared only when you ask for it, field by field:

app/Providers/AppServiceProvider.php
LaRecipe::shareUser(fn ($user) => ['name' => $user->name]);

Values a component needs from one page go under meta: in that page's frontmatter, and arrive as page.meta:

yaml
---
title: Pricing
meta:
  plan: enterprise
---

meta is the only frontmatter beyond LaRecipe's own keys that reaches the browser. Other unknown keys stay on the server, because they often hold notes for the team, such as an owner or a ticket. See frontmatter.

The extension entry

One entry file registers every custom component of your app:

resources/larecipe/extension.tsx
import { defineExtension } from '@larecipe/ui'
import { DocsFlow } from './DocsFlow'
import { InstallTabs } from './InstallTabs'
import { Subscribe } from './Subscribe'
import './docs-flow.css'
import './install-tabs.css'
import './subscribe.css'
// Server-only components (no React): only their styles ship here.
import './byline.css'
import './featured-on.css'
import './features.css'

// Our LaRecipe extension: the React side of the components this site adds.
// Each one is also defined in PHP (AppServiceProvider) with its props, its
// server fallback and its Markdown form.
export default defineExtension({ components: { DocsFlow, InstallTabs, Subscribe } })

Its CSS reads LaRecipe's theme variables, so the form follows a team's accent, dark mode and right-to-left pages with no extra rules. The same rules style the React form and the HTML fallback, because both use the same class names.

resources/larecipe/subscribe.css
resources/larecipe/subscribe.css
/*
 * The newsletter form. It reads LaRecipe's theme tokens, so it follows the
 * site's accent, dark mode and direction. The same rules style the server
 * fallback (no JavaScript) and the React form.
 */
.lr-subscribe-wrap {
  margin-block: 40px 8px;
}

.lr-subscribe {
  display: grid;
  gap: 20px;
  padding: 24px;
  border: 1px solid color-mix(in srgb, var(--lr-accent-current) 20%, var(--lr-hairline));
  border-radius: var(--lr-radius-lg);
  background:
    radial-gradient(120% 160% at 100% 0%, color-mix(in srgb, var(--lr-accent-current) 9%, transparent), transparent 62%),
    var(--lr-paper);
  box-shadow: var(--lr-shadow-xs);
}

[dir='rtl'] .lr-subscribe {
  background:
    radial-gradient(120% 160% at 0% 0%, color-mix(in srgb, var(--lr-accent-current) 9%, transparent), transparent 62%),
    var(--lr-paper);
}

.lr-prose .lr-subscribe p,
.lr-subscribe p {
  margin: 0;
}

.lr-subscribe-head {
  display: flex;
  align-items: flex-start;
  gap: 14px;
}

.lr-subscribe-icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--lr-radius-md);
  background: var(--lr-accent-soft);
  color: var(--lr-accent-current);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lr-accent-current) 18%, transparent);
}

.lr-subscribe-copy {
  display: grid;
  gap: 4px;
  min-width: 0;
}

.lr-subscribe-label {
  font-family: var(--lr-font-mono);
  font-size: 11px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--lr-accent-current);
}

.lr-subscribe .lr-subscribe-title {
  margin-block-start: 2px;
  font-size: 17px;
  font-weight: 600;
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--lr-ink);
}

.lr-subscribe .lr-subscribe-body {
  font-size: 14px;
  line-height: 1.5;
  color: var(--lr-ink-muted);
}

/* The field and the button share one bordered row, focused as one. */
.lr-subscribe-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px;
  border: 1px solid var(--lr-hairline);
  border-radius: calc(var(--lr-radius-md) + 4px);
  background: var(--lr-surface);
  transition: border-color var(--lr-duration-hover) var(--lr-ease), box-shadow var(--lr-duration-hover) var(--lr-ease);
}

.lr-subscribe-row:hover {
  border-color: var(--lr-hairline-strong);
}

.lr-subscribe-row:focus-within {
  border-color: var(--lr-accent-current);
  box-shadow: 0 0 0 3px var(--lr-accent-ring);
}

.lr-subscribe-field {
  flex: 1 1 auto;
  display: flex;
  min-width: 0;
}

.lr-subscribe-field .lr-input,
.lr-subscribe-field .lr-input:hover,
.lr-subscribe-field .lr-input:focus,
.lr-subscribe-field .lr-input:focus-visible {
  width: 100%;
  border-color: transparent;
  background: transparent;
  outline: none;
  box-shadow: none;
}

.lr-subscribe-row .lr-button {
  flex: none;
}

.lr-subscribe .lr-subscribe-error {
  font-size: 14px;
  line-height: 1.5;
  color: var(--lr-danger);
}

/* The quiet line under the form on the Introduction (showcase). */
.lr-prose .lr-subscribe-note,
.lr-subscribe-note {
  margin: 10px 4px 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--lr-ink-muted);
}

.lr-prose .lr-subscribe-note a,
.lr-subscribe-note a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--lr-hairline-strong);
  text-underline-offset: 3px;
}

.lr-prose .lr-subscribe-note a:hover,
.lr-subscribe-note a:hover {
  color: var(--lr-ink);
  text-decoration-color: currentColor;
}

:root:lang(ar) .lr-subscribe-label {
  font-family: var(--lr-font-sans);
  font-size: 12.5px;
  letter-spacing: normal;
  text-transform: none;
}

The PHP definition

Every custom component needs a definition in PHP, usually in AppServiceProvider::boot(). It names the component, describes its props, and gives its HTML fallback and its Markdown form:

app/Providers/AppServiceProvider.php
LaRecipe::component('Subscribe')
    ->description('A sign-up form for the LaRecipe newsletter.')
    ->props([
        'list' => ['type' => 'string', 'default' => 'larecipe', 'description' => 'The newsletter list to join'],
        'showcase' => ['type' => 'boolean', 'default' => false, 'description' => 'Add a faded line under the form that links to how it is built'],
    ])
    ->fallback(function (array $props, string $children, RenderedComponent $component) {
        $locale = in_array($component->locale, SubscribeController::LOCALES, true) ? $component->locale : 'en';

        return view('docs.subscribe', [
            'list' => $props['list'],
            'locale' => $locale,
            'showcase' => (bool) $props['showcase'],
            'guide' => url(($locale === 'ar' ? 'ar/' : '').($component->version ?? '3.x').'/customize/custom-components'),
        ]);
    })
    ->markdown(function (array $props, string $children, RenderedComponent $component) {
        $locale = in_array($component->locale, SubscribeController::LOCALES, true) ? $component->locale : 'en';
        $line = __('newsletter.markdown', ['url' => url('/newsletter')], $locale);

        if (! $props['showcase']) {
            return $line;
        }

        return $line."\n\n".__('newsletter.note', [], $locale).' '.__('newsletter.note_link', [], $locale).': '
            .url(($locale === 'ar' ? 'ar/' : '').($component->version ?? '3.x').'/customize/custom-components');
    })
    ->island();

props() describes each prop, fallback() returns the server HTML, markdown() returns what agents read, and island() lets React mount over the fallback. A tag with no PHP definition is an unknown component: it renders as escaped text, and php artisan larecipe:validate reports components.unknown. So a page can never ask the browser to run code your app did not register.

Every definition method
Method What it sets
props([...]) Each prop's type, and optionally default, required, enum and description (or the short form 'list' => 'string'). larecipe:validate and every render check them: a missing required prop or a value outside enum is a components.props error. On a repository set, props you do not describe still reach React. On a database set, whose authors may be less trusted than your codebase, they are dropped, so describe every prop you read.
fallback(fn (array $props, string $children, RenderedComponent $context) => ...) The server HTML. Return a string, a view or anything Htmlable. $children is the rendered Markdown inside the tag, and $context carries the set, version, locale and page path.
markdown(fn (array $props, string $children, $context) => ...) The line or block agents read. Without it, agents get the children.
island() Wraps the fallback in a placeholder React mounts into. Without it, an inline tag stays as its fallback HTML and React never runs. At insert points, components are always wrapped.
raw() Keeps the content between the tags as literal text instead of Markdown.
description('...') One sentence for the generated component reference agents read.

The fallback

The fallback is what a reader without JavaScript gets, so make it work on its own. Subscribe's is a plain form that posts to the same route:

resources/views/docs/subscribe.blade.php
{{--
    The Subscribe component without JavaScript: a plain form that posts to
    /newsletter/subscribe and lands on /newsletter with the outcome. Its markup
    matches resources/larecipe/Subscribe.tsx, so React mounts without a shift,
    and it speaks the page's language ($locale: en or ar).
    The page is cached and shared, so the CSRF token comes from csrfField(),
    which LaRecipe fills in on every response.
--}}
<div class="lr-subscribe-wrap">
    <form class="lr-subscribe" method="post" action="/newsletter/subscribe" data-lr-subscribe="html">
        {!! \LaRecipe\Facades\LaRecipe::csrfField() !!}
        <input type="hidden" name="list" value="{{ $list }}">
        <input type="hidden" name="locale" value="{{ $locale }}">
        <div class="lr-subscribe-head">
            <span class="lr-subscribe-icon" aria-hidden="true">@include('larecipe::partials.icon', ['name' => 'Mail', 'iconSize' => 18, 'iconClass' => ''])</span>
            <div class="lr-subscribe-copy">
                <p class="lr-subscribe-label">{{ __('newsletter.label', [], $locale) }}</p>
                <p class="lr-subscribe-title">{{ __('newsletter.title', [], $locale) }}</p>
                <p class="lr-subscribe-body">{{ __('newsletter.body', [], $locale) }}</p>
            </div>
        </div>
        <div class="lr-subscribe-row">
            <label class="lr-subscribe-field">
                <span class="lr-sr">{{ __('newsletter.email', [], $locale) }}</span>
                <input class="lr-input" type="email" name="email" required autocomplete="email" placeholder="you@example.com" dir="ltr">
            </label>
            <button class="lr-button lr-button-primary" type="submit">{{ __('newsletter.submit', [], $locale) }}</button>
        </div>
    </form>
    @if ($showcase)
        <p class="lr-subscribe-note">{{ __('newsletter.note', [], $locale) }} <a href="{{ $guide }}">{{ __('newsletter.note_link', [], $locale) }}</a></p>
    @endif
</div>

Rendered articles are cached and shared between readers, so a fallback must not bake in anything that belongs to one request. LaRecipe::csrfField() prints a placeholder that LaRecipe fills with the reader's CSRF token on every response. Without JavaScript, the form posts and lands on the newsletter page, a plain page that shows whether it worked.

The Markdown form

Agents should not fill in forms, so Subscribe's Markdown form is one line with a link, plus the faded note when showcase is set. Read the Introduction as Markdown and you will find it near the end:

markdown
LaRecipe release notes by email. Unsubscribe any time. Subscribe with your email address: https://larecipe.saleem.dev/newsletter

This form is a custom React component this site adds to LaRecipe. See how it is built: https://larecipe.saleem.dev/3.x/customize/custom-components
In the reader's language

A component can follow the page's language like the built-in UI does. Subscribe reads locale from useLaRecipe() in React, and $context->locale in its fallback, then picks its words from lang/en/newsletter.php or lang/ar/newsletter.php. On the Arabic Introduction the same form reads right to left, in Arabic, and the route answers in Arabic too.

The route the form posts to

The React form and the fallback both post to an ordinary route in your app, with your own middleware. This is that route, read from the running app:

Route POST /newsletter/subscribe
MethodPOST
URI/newsletter/subscribe
Middlewareweb, throttle

The controller validates the address and sends it to Nashra. It never fails with a 500: a missing API key or an unreachable service comes back as one friendly sentence, as JSON for request() and as a redirect for the plain form.

app/Http/Controllers/SubscribeController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Nashra\Sdk\Exceptions\AuthenticationException;
use Nashra\Sdk\Exceptions\AuthorizationException;
use Nashra\Sdk\Exceptions\RateLimitException;
use Nashra\Sdk\Exceptions\ValidationException;
use Nashra\Sdk\Facades\Nashra;
use Throwable;

/**
 * POST /newsletter/subscribe: adds a reader to the LaRecipe newsletter on Nashra.
 *
 * Called by the Subscribe component in two ways: as JSON from the React form
 * (through request() from @larecipe/ui), and as a plain form post from the
 * server fallback when JavaScript is off. A plain post lands on /newsletter,
 * which can show the outcome; the docs page itself is cached HTML and cannot.
 *
 * It answers in the page's language (English or Arabic), and it never fails
 * with a 500: a missing SDK or API key, an unreachable API or a rejected
 * address each come back as one plain sentence.
 */
final class SubscribeController
{
    public const LOCALES = ['en', 'ar'];

    public function __invoke(Request $request): JsonResponse|RedirectResponse
    {
        $locale = in_array($request->input('locale'), self::LOCALES, true) ? $request->input('locale') : 'en';

        $data = $request->validate([
            'email' => ['required', 'string', 'email', 'max:254'],
            'list' => ['nullable', 'string', 'max:64'],
            'source' => ['nullable', 'string', 'max:255'],
            'locale' => ['nullable', 'string'],
        ], [
            'email.required' => __('newsletter.required', [], $locale),
            'email.email' => __('newsletter.invalid', [], $locale),
        ]);

        [$subscribed, $message, $field] = $this->subscribe($data['email'], $locale);

        if ($request->expectsJson()) {
            return $subscribed
                ? response()->json(['subscribed' => true])
                : response()->json([
                    'subscribed' => false,
                    'message' => $message,
                    'errors' => $field ? [$field => [$message]] : null,
                ], $field ? 422 : 503);
        }

        $page = redirect()->route('newsletter', $locale === 'en' ? [] : ['lang' => $locale]);

        return $subscribed
            ? $page->with('subscribed', $data['email'])
            : $page->withInput()->withErrors(['email' => $message]);
    }

    /** @return array{bool, ?string, ?string} subscribed, a message for the reader, the field it is about */
    private function subscribe(string $email, string $locale): array
    {
        if (! class_exists(Nashra::class) || blank(config('nashra.api_key'))) {
            return [false, __('newsletter.not_connected', [], $locale), null];
        }

        try {
            Nashra::subscribers()->create(['email' => $email]);

            return [true, null, null];
        } catch (ValidationException $exception) {
            return [false, $exception->errors()['email'][0] ?? __('newsletter.rejected', [], $locale), 'email'];
        } catch (RateLimitException) {
            return [false, __('newsletter.rate_limited', [], $locale), null];
        } catch (AuthenticationException|AuthorizationException $exception) {
            report($exception);

            return [false, __('newsletter.not_connected', [], $locale), null];
        } catch (Throwable $exception) {
            report($exception);

            return [false, __('newsletter.unavailable', [], $locale), null];
        }
    }
}

Build it

Custom components are the one part of LaRecipe that needs Node, and only at build time, on your machine or in CI. Add @larecipe/vite and @larecipe/ui as dev dependencies, then add the plugin to your Vite config. @larecipe/vite works with Vite 6, 7 and 8. This is this site's own config:

vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import larecipe from '@larecipe/vite';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        tailwindcss(),
        // Builds our LaRecipe extension (the React side of the Subscribe component)
        // into public/build/larecipe/, which LaRecipe loads on every docs page.
        larecipe({ input: 'resources/larecipe/extension.tsx' }),
    ],
    server: {
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});

npm run build then writes the extension to public/build/larecipe/. Your server never runs Node: the built files are static. LaRecipe looks for public/build/larecipe/manifest.json on every docs page and, when the extension's entry is there, adds its stylesheet and module right after its own runtime. There is no script tag to add by hand.

One React on the page

The build is an ES module with a manifest. react, react-dom and @larecipe/ui are left out of the bundle on purpose. Every docs page prints an import map ahead of LaRecipe's scripts, which points the bare names react, react/jsx-runtime, react-dom, react-dom/client and @larecipe/ui at LaRecipe's own built files:

html
<script type="importmap">{"imports":{"react":"/larecipe-assets/{build}/assets/react-….js","@larecipe/ui":"/larecipe-assets/{build}/assets/larecipe-ui-….js", "…": "…"}}</script>

Your extension keeps those imports bare, so the browser resolves them to the same modules LaRecipe uses. There is one React and one component registry, and hooks such as useLaRecipe() work inside your components. defineExtension() works whether your module loads before or after LaRecipe's runtime.

Use it

Inline, in Markdown. Write the tag on its own line, with blank lines around it, like any built-in. Props are strings:

markdown
<Subscribe list="larecipe" />

On every page. Place it at an insert point in larecipe.json, as this site does with "inserts": { "article.after": ["Subscribe"] }. See Insert points and slots.

Reader Gets
A browser with JavaScript The React form, which posts with request() and answers in place
A browser without JavaScript, a crawler The fallback form, which posts and lands on /newsletter
An agent reading .md or MCP One line: the newsletter's URL

Next

Newsletter

Stay in the loop

LaRecipe release notes by email. Unsubscribe any time.