Building with Fancy

SEO & SSR

Real content on the first byte — server-side rendering, per-page meta + Open Graph + JSON-LD, sitemap / robots / llms.txt.

A Fancy UI app is an Inertia + React SPA — by default the browser renders it after the JavaScript loads. That's great for humans, but a search crawler, a social-card scraper, or an LLM that fetches your URL sees an empty shell on the first byte. Fancy closes that gap with two pieces that work together: server-side rendering (real HTML in the first byte) and a server-rendered SEO baseline (complete <title>, meta, Open Graph, and structured data before any JS runs). The result is a site that's fully crawlable, shares with rich cards, and is legible to LLMs — without giving up the SPA experience.

Two packages provide it:

  • @particle-academy/fancy-inertia — the SSR server entry + a per-page <Seo> head helper (/server and /seo subpaths).
  • particle-academy/fancy-seo — the Laravel side: the server-rendered head baseline plus sitemap.xml / robots.txt / llms.txt and per-page Markdown.

Server-side rendering

Inertia ships SSR; fancy-inertia makes wiring it a couple of lines, and keeps the server and client render the exact same tree so hydration is clean.

The SSR entryresources/js/ssr.tsx:

import { createFancyServer } from "@particle-academy/fancy-inertia/server";
import { FancyDataRoot } from "@particle-academy/fancy-query";

createFancyServer({
  resolve: (name) => {
    const pages = import.meta.glob("./Pages/**/*.tsx");
    return pages[`./Pages/${name}.tsx`]().then((m) => m.default);
  },
  providers: (outlet) => <FancyDataRoot echo={null}>{outlet}</FancyDataRoot>,
});

The client entry — swap the mount for setupFancyApp, which auto-picks hydrateRoot (when SSR is on) vs createRoot (when it isn't), so turning SSR on or off is a config change, not a code change:

import { createInertiaApp } from "@inertiajs/react";
import { setupFancyApp } from "@particle-academy/fancy-inertia";
import { FancyDataRoot } from "@particle-academy/fancy-query";

createInertiaApp({
  resolve,
  setup: ({ App, props, el }) =>
    setupFancyApp({
      el, App, props,
      providers: (outlet) => <FancyDataRoot echo={null}>{outlet}</FancyDataRoot>,
    }),
});

Build + run — add the SSR bundle to your Vite config and start the renderer:

// vite.config.js — inside laravel({ ... })
ssr: "resources/js/ssr.tsx",
vite build && vite build --ssr      # emits bootstrap/ssr/ssr.js
php artisan inertia:start-ssr        # the SSR renderer (run under supervisor in prod)

Set INERTIA_SSR_ENABLED=true. If the renderer is down, Inertia falls back to client rendering automatically — SSR is purely additive, never a hard dependency.

Keeping components SSR-safe

Most react-fancy components render fine on the server. Anything that needs the browser — a canvas chart (fancy-echarts), WebGL (fancy-3d), a code editor, an IntersectionObserver — wrap in <FancyClientOnly> so it renders a fallback on the server and mounts on the client:

import { FancyClientOnly } from "@particle-academy/fancy-inertia";

<FancyClientOnly fallback={<div className="h-96 animate-pulse rounded bg-zinc-100" />}>
  <EChart option={option} />
</FancyClientOnly>

The static parts of the page — headings, copy, tables — render on the server and appear in the first byte; only the interactive widget hydrates. See the SSR matrix for which components need a wrapper.

Per-page SEO with <Seo>

@particle-academy/fancy-inertia/seo turns per-page SEO into one component. It renders into Inertia's <Head> with head-key dedupe, so it cleanly overrides the server baseline on SPA navigation:

import { Seo, softwareSourceCode, breadcrumbList } from "@particle-academy/fancy-inertia/seo";

export default function PackagePage({ pkg }) {
  return (
    <>
      <Seo
        title={`${pkg.name} — Fancy UI`}
        description={pkg.tagline}
        canonical={pkg.url}
        image={pkg.ogImage}
        jsonLd={[
          softwareSourceCode({ name: pkg.name, url: pkg.url, codeRepository: pkg.repo, programmingLanguage: "TypeScript" }),
          breadcrumbList([{ name: "Packages", url: "/packages" }, { name: pkg.name, url: pkg.url }]),
        ]}
      />
      {/* page… */}
    </>
  );
}

It emits <title>, the meta description, canonical link, robots, the full Open Graph + Twitter card set, and one <script type="application/ld+json"> per JSON-LD node. The builders (website, organization, softwareApplication, softwareSourceCode, article, breadcrumbList, faqPage, collectionPage, product) are dependency-free and return plain schema.org objects. Set noindex on admin / auth pages.

The server baseline: fancy-seo

<Seo> is the client layer. For the first byte — what a crawler sees before any JS — the particle-academy/fancy-seo Laravel package renders the default head on the server.

composer require particle-academy/fancy-seo

Drop one tag in your root Blade template's <head>:

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <x-fancy-seo::head />

  @vite(['resources/js/app.tsx'])
  @inertiaHead
</head>

Register defaults and per-route SEO from a service provider — jsonLd accumulates across layers, everything else overrides (defaults() → per-route → for()):

use FancySeo\Facades\FancySeo;
use FancySeo\JsonLd;

FancySeo::defaults([
    'site_name' => 'Fancy UI',
    'image' => '/og/default.png',
    'jsonLd' => [JsonLd::website('Fancy UI', url('/'))],
]);

FancySeo::route('packages.show', fn (array $p) => [
    'title' => "{$p['package']} — Fancy UI",
    'jsonLd' => [JsonLd::softwareSourceCode($p['package'], url("/packages/{$p['package']}"), "https://github.com/Particle-Academy/{$p['package']}")],
]);

FancySeo::noindexRoutes(['admin.*', 'auth.*']);

A controller can also set the payload for the current request: FancySeo::for(['title' => $post->title, 'type' => 'article']).

Crawl surfaces — sitemap, robots, llms.txt

fancy-seo auto-registers the discovery endpoints (toggle each in config/fancy-seo.php):

Route Purpose
/sitemap.xml every URL you register via FancySeo::sitemap(...)
/robots.txt crawl policy — welcomes LLM bots, references the sitemap
/llms.txt, /llms-full.txt a Markdown index for LLMs (llmstxt.org)
/{path}.md per-page raw Markdown (opt-in)
/.well-known/security.txt, /humans.txt the usual well-knowns
FancySeo::sitemap(function ($map) {
    $map->add('/', '1.0', 'daily');
    foreach (Package::all() as $p) {
        $map->add("packages/{$p->slug}", '0.8');
    }
});

FancySeo::llms(fn ($seo) => view('seo.llms')->render());
FancySeo::markdownUsing(fn (string $path) => MarkdownContent::for($path)); // null → 404

How the two layers fit

  • fancy-seo (server) renders the default head into the first byte, so a crawler with no JavaScript gets a correct title, description, canonical, Open Graph card, and JSON-LD on every URL.
  • <Seo> (client) overrides it per page using matching head-keys, so SPA navigations and page-specific structured data stay current after hydration.

Use both: the server baseline for crawlers and social/LLM scrapers, <Seo> for per-page detail. They never fight — the head-keys dedupe.

Built to be read by machines

The same content that makes you rank also makes your app legible to agents. With SSR on, an LLM that fetches a page gets the real rendered content; /llms.txt gives it a curated index; per-page .md gives it clean Markdown instead of parsed HTML. That's the same Human+ principle the rest of the kit follows — the surface is built for humans and machines from the first byte.