Sharing a Simple Way to Make Phoenix Support React SSR

TL;DR: Ask AI to write an Elixir version of the React HTML generation logic, producing HTML that matches React renderToString, so React can hydrate it directly.

I have been thinking about one technical problem for at least ten years: if the web frontend is a pure JavaScript SPA, how should the backend solve SEO and render HTML for the first screen?

Ten years ago, when I was using Backbone, our solution was to write the template rendering logic once in Python, then write the exact same thing again in JavaScript.

Today, the most popular industry solution is to use the Node ecosystem, especially Next.js, to render on both the frontend and the backend. There are even strange architectures like Next.js → Go.

Beyond Next.js, there are other industry approaches too. Discourse, for example, uses Ember and also writes the logic twice: once in Ruby and once in Ember.

Then today I suddenly had a thought: since React derives the Fiber tree from existing HTML during hydration, why not just ask AI to make the backend output HTML that is exactly the same as what React’s Node renderer would output? Then React can hydrate normally, and the problem is solved.

The concrete approach:

  1. Ask AI to reference the React code and write an Elixir version of the HTML generation logic.
  2. Make the generated HTML match the output of React renderToString, so React can hydrate it automatically.
  3. Provide a script on the React side that outputs the renderToString result and uses it as feedback for the Elixir code.

In practice, it works very smoothly. The result is excellent.

Other Approaches I Tried

In this project, I also considered or tried several other approaches:

  1. Use edgurgel/solid to let the frontend and backend share Liquid-style templates. This would avoid writing the template twice, but the frontend logic would need to be implemented with jQuery + Backbone, because JSX is not a string template.
  2. Implement something like PM2 in Elixir, call Node from there, and let Node run renderToString.

In the end, I used the simplest approach.

The Prompt Used for This Approach

# Elixir + React SEO Hydration Plan

## Scope

This plan only covers the frontend page content under `/web/*`. `/web/admin/*` is explicitly out of scope: the admin backend has low SEO value, uses an independent admin entry, does not participate in hydration, does not construct `initial_state`, does not appear in the route whitelist, and does not connect to the Elixir renderer. All contracts, renderers, and validation items below do not apply to `/web/admin/*`.

## Conclusion

`/web/*` can adopt a plan where "Elixir generates SEO first-screen HTML, and browser-side React takes over with `hydrateRoot`", but this is not traditional React SSR, and it is not "one piece of React code running on both sides".

The recommended definition is:

- Elixir is responsible for production first-screen HTML, first-screen data fetching, permission checks, and state injection.
- React remains responsible for browser-side interaction, router takeover, component lifecycle, React Query cache, and later refreshes.
- The Elixir renderer and React renderer jointly obey the same page contract.
- During development, keep a Node `renderToString` reference output to validate whether the Elixir output has drifted away from the React DOM shape.

This plan is suitable for gradual page-by-page adoption, starting with public pages that have high SEO value. It is not recommended to cover all of `/web/*` at the beginning.

## Goals

- Search engines and no-JS crawlers can directly obtain complete first-screen content.
- Browser-side React uses `hydrateRoot` to reuse existing HTML and avoids clearing the first screen again.
- Production does not depend on a Node SSR service and does not run React on the backend.
- First-screen data is fetched only once, and the same data is used for Elixir HTML, Redux preloaded state, and the initial TanStack Query cache.
- Hydration warnings must be 0, and events must bind normally after hydration.

## Non-Goals

- Do not move React components directly into Elixir execution.
- Do not require all `/web/*` pages to support hydration at once.
- Do not put all TanStack Query data into Redux.
- Do not ask Elixir to construct TanStack Query's internal dehydrated cache format.
- Do not cover `/web/admin/*`. See "Scope".

## Current State

Currently, `/web/*` is an SPA shell:

- In production, `/web/*` is returned by `CatWeb.WebController` as `dist_index`.
- In `dist_index.html.heex`, `#root` is empty.
- `web/src/main/main.jsx` creates a `QueryClient` at startup, first dispatches `preloadClassesConfig()` and `preloadPageLayout()`, then renders React with `createRoot`.
- Redux only stores globally shared state: `currentScope`, `classesConfig`, `pageLayout`, and `sidebarCommon`.
- Page data is mostly managed by TanStack Query. `useQuery` results enter only the Query cache and are not automatically written into Redux.
- A few scenarios perform manual synchronization, such as updating `currentScope` after login, or updating both Redux and Query cache after profile updates.

Therefore, introducing hydration requires changes to the HTML shell, React startup entry, Redux store creation, QueryClient initial data seeding, and the first-screen data contract for each SEO page.

## Page Contract

The Elixir renderer and React renderer must share the following contract.

### DOM Shape

For the same page and the same state, the DOM inside `#root` output by Elixir must match the first render result from React:

- Same tag names.
- Same nesting order.
- Same class, id, aria, and data attributes.
- Same boolean attributes.
- Same list item order.
- Same conditional rendering branches.

As long as the first screen participates in hydration, Elixir and React must not use different logic to decide loading, permission, empty state, error state, or feature flag branches.

### Initial State

Elixir constructs one unique `initial_state` within the request. The same `initial_state` is used for:

- Elixir synchronous first-screen HTML rendering.
- Injecting `window.__INITIAL_STATE__`.
- Creating the Redux store `preloadedState`.
- Seeding the TanStack Query cache.

Do not let the Elixir template and React hydration fetch first-screen data separately. Time, random values, permission branches, or API response differences can all cause hydration mismatches.

### Client Boundaries

Do not decide whether something is "client-only" by component. Decide it by the **source of the input**. Within the same component, some inputs can be provided by the server, while others can only be obtained by the browser. These two types of inputs should be handled separately.

#### Inputs the Server Can Actually Provide

Values like current time, logged-in user, URL, language, and timezone may look like "browser things", but the server can get them from the request or provide a good enough approximation.

These inputs should go into `initial_state`, and the component should be SSR-rendered normally. **Do not wrap it with `<ClientOnly>`**. If the client wants to use a more accurate value after mount, such as refreshing "5 minutes ago" into "6 minutes ago", let the component update silently in `useEffect`.

#### Inputs Only the Browser Knows

Examples include user preferences in `localStorage`, scroll position, dimensions measured by `ResizeObserver`, and content injected by third-party SDKs. The server truly cannot obtain these.

These should use `<ClientOnly>` to wrap the corresponding DOM subtree. The server and the first React render both output a placeholder, then replace it with the real content after mount. These data do not go into `initial_state`, because they are unavailable by definition.

#### Things That Look Like Browser Concerns but Can Be Handled by CSS

Cases like "hide sidebar on narrow screens" or "switch column count according to width" conceptually depend on the viewport, but they can be fully handled by `@media` or `grid auto-fit` at the CSS layer. The server should output all DOM, and visibility should be handled by CSS.

Only when CSS really cannot solve the problem, such as a virtual list that must measure dimensions, should this fall back to the previous category and use `<ClientOnly>`.

#### Components Where SSR Is Intentionally Abandoned

Some components can theoretically be SSR-rendered, but there is no practical need: SEO does not care, they are not visible on the first screen, or the implementation cost is disproportionate to the value. For example, heavy editors like `AvatarEditor`, rich-text editors, and chart libraries are not worth mirroring in server-side DOM.

These components can also be directly wrapped with `<ClientOnly>`. The reason changes from "browser API limitation" to "intentional developer judgment". They use the same mechanism, but the mental model is different:

- **Forced**: SSR is technically impossible because the data only exists in the browser.
- **Intentional**: SSR is technically possible, but we choose not to do it.

Common criteria for "intentionally abandon SSR":

- The component only appears after user interaction, such as opening a dialog or clicking an edit button, and is not in the first-screen DOM at all. This usually works with lazy import and does not even need `<ClientOnly>`, because it never enters the first screen.
- The component is in the first-screen DOM, but has no SEO impact, such as an avatar editing entry, initial state of an embedded player, or a visualization chart, and mirroring the DOM is expensive. Use `<ClientOnly>` and let the server output only a placeholder.
- The React subtree the component depends on is too complex, such as animation libraries or complex state machines, and is not worth maintaining on the Elixir side. This also falls into intentional abandonment.

#### Decision Order

When encountering something that "looks browser-dependent" or "does not seem worth SSR", ask in this order:

1. Is the component not in the first-screen DOM at all and only mounted after interaction? -> lazy import, no `<ClientOnly>`, and no `initial_state`.
2. Is it on the first screen, but depends on input that only exists in the browser? -> `<ClientOnly>`.
3. Is it on the first screen, and is it a layout or visual condition? -> CSS.
4. Is it on the first screen, theoretically SSR-capable, but the developer judges it not worth it? -> `<ClientOnly>`.
5. Otherwise -> normal SSR, with required data in `initial_state`.

#### The Boundary of `<ClientOnly>`

It wraps a **DOM subtree**, not an entire component. A component may have only a small part whose input is browser-only; in that case, wrap only that part, while the rest of the component continues to SSR normally.

The placeholder only needs to be byte-identical between the server and the first client render. It does not need to be invisible; a skeleton placeholder or empty `<span>` is fine. The first React render outputs the placeholder, then replaces it with real children after `useEffect`; the Elixir renderer outputs the same placeholder DOM.

### HTML Whitespace

Inside `#root`, do not directly insert indentation and newlines that create whitespace text nodes. React hydration treats these whitespace nodes as real text nodes during comparison, which can mismatch React's first render if it does not output the same whitespace.

The simplest approach is to flatten all DOM into one line:

```html
<div id="root"><div class="app"><header>...</header><main>...</main></div></div>
```

But production pages become almost unreadable this way, making debugging and acceptance checks painful.

The compromise used by the Elixir renderer: **use HTML comments to carry newlines and indentation**. Comments do not become DOM text nodes, so they do not affect hydration. At the same time, browser "view source" still shows an aligned hierarchy, which helps humans check DOM shape.

Recommended output style:

```html
<div id="root"><!--
--><div class="app"><!--
  --><header><!--
    --><h1>Title</h1><!--
  --></header><!--
  --><main><!--
    --><p>Body</p><!--
  --></main><!--
--></div><!--
--></div>
```

Key points:

- Every newline + indentation is wrapped in a pair of `<!-- -->`, placed directly after the previous tag's `>` or directly before the next tag's `<`.
- Put only newline characters and spaces inside comments. Do not write explanatory text, to avoid confusing them with debugging comments and to avoid misreading them during hydration mismatch investigation.
- Indentation style, whether 2 spaces or 4 spaces, is centrally controlled in `CatWeb.WebSSR.HTML`; page renderers should not concatenate whitespace themselves.
- Still do not insert comments between attributes inside a tag. Comments are only for "newlines between blocks" and must not enter the attribute area.

Avoid:

```html
<div id="root">
  <div class="app">...</div>
</div>
```

This kind of "bare newline + bare indentation" creates extra text nodes under `#root` and directly breaks hydration.

Outside `#root`, head, script, link, and similar elements can be formatted normally; there is no need to wrap newlines in comments.

The React reference renderer's `renderToString` output will not include these comments. DOM structure comparison tests need to strip comment nodes before comparison, or compare only element nodes; otherwise, comment differences will create false positives.

## State Contract

It is recommended to inject a project-specific state, not TanStack Query's dehydrated state directly.

```js
window.__INITIAL_STATE__ = {
  version: 1,
  route: {
    path: "/web/subjects/123",
    canonicalPath: "/subjects/123",
    params: {
      subjectId: "123"
    }
  },
  redux: {
    currentScope: {},
    classesConfig: {},
    pageLayout: {},
    sidebarCommon: {}
  },
  queries: [
    {
      queryKey: ["subject", "123"],
      data: {},
      updatedAt: 1710000000000
    }
  ],
  page: {
    kind: "subject_detail",
    seo: {
      title: "..."
    }
  }
}
```

### Redux

Redux continues to carry only globally shared state:

- Login state and permissions: `currentScope`.
- Global class configuration: `classesConfig`.
- Current page layout: `pageLayout`.
- Sidebar legacy state that still needs to be shared: `sidebarCommon`.

`web/src/main/store/index.js` needs to change from exporting a singleton `store` to exporting `createStore`:

```js
export function createStore(preloadedState) {
  return configureStore({
    reducer: {
      currentScope: currentScopeReducer,
      classesConfig: classesConfigReducer,
      pageLayout: pageLayoutReducer,
      sidebarCommon: sidebarCommonReducer,
    },
    preloadedState,
  })
}
```

For ordinary SPA pages that do not support SSR, create the store with an empty state.

### TanStack Query

TanStack Query data does not go into Redux. Reasons:

- Double-writing Redux and Query cache creates consistency problems.
- React Query already handles stale state, refetch, invalidation, pagination, and optimistic updates.
- Current project page data is already mostly managed by `useQuery`.

#### Query Factory

Queries that participate in first-screen hydration must be defined through a unified factory, which is the single source of truth for `queryKey`, `queryFn`, `staleTime`, and refetch policy:

```js
// web/src/main/queries.js
export const subjectQuery = (id) => ({
  queryKey: queryKeys.subject(id),
  queryFn: ({ signal }) => fetchSubject(id, { signal }),
  staleTime: 60_000,
})
```

Business code calls through the factory uniformly:

```js
const { data } = useQuery(subjectQuery(id))
```

`staleTime`, `refetchOnWindowFocus`, and `refetchOnReconnect` must be written in the factory. They must not be overridden again at the `useQuery` call site, and must not depend on the `QueryClient` global default. This ensures that the seeded cache entry and the configuration read by `useQuery` are always consistent, avoiding a race where "seed uses one staleTime, but `useQuery` overrides it with another".

#### seedQueryClient

Seeding only puts the data fetched by Elixir into cache together with `updatedAt`; it does not set staleTime:

```js
export function seedQueryClient(queryClient, initialState) {
  for (const query of initialState?.queries ?? []) {
    if (!Array.isArray(query.queryKey)) continue

    queryClient.setQueryData(query.queryKey, query.data, {
      updatedAt: query.updatedAt,
    })
  }
}
```

staleTime is provided by the factory. When `useQuery` runs for the first time, it reads the factory value and compares it with `dataUpdatedAt` in the cache. If the data is fresh, it does not trigger refetch.

#### updatedAt Contract

`initial_state.queries[].updatedAt` must be the **millisecond timestamp at which Elixir finished fetching data**:

- It must not be 0, a constant, or the template render time.
- It must not reuse the upstream cache write time, which may be much earlier than the current request.
- It is recommended to call `System.system_time(:millisecond)` immediately after data fetching completes in the Elixir controller, and use that as the query's `updatedAt`.

As long as `now - updatedAt < staleTime`, `useQuery` will determine the data is fresh during hydration and will not issue a refetch request.

#### Lower Bound for staleTime

For queries participating in first-screen hydration, `staleTime` must be >= 30s, covering the full gap from Elixir render -> network transfer -> JS parsing -> hydration, with extra margin for slow networks. Data below this threshold should not enter the SEO hydration set; it should either use client fetch or not participate in the first screen.

#### Do Not Let Elixir Generate Dehydrated State

Do not let Elixir generate TanStack Query's internal dehydrated shape. That is a TanStack implementation detail, more suitable for Node SSR and not suitable for the Elixir production path.

### Unified queryKey

All queryKeys participating in first-screen hydration must be centrally defined to avoid drift between hand-written Elixir and React.

Recommended addition:

```js
export const queryKeys = {
  subject: (id) => ["subject", id],
  subjectReviews: (id, page, rating) => ["subject-reviews", id, page, rating],
  post: (id) => ["post", id],
  userProfile: (idname) => ["user-profile", idname],
}
```

The Elixir side should not guess queryKey strings by itself. Tests can verify that queryKeys output by Elixir match the JS convention, or a clear cross-language queryKey table can be maintained.

### First-Screen Query Coverage

Each SEO page needs to list the queries that will render on the first screen:

- Main page resource.
- First-screen visible list.
- First-screen sidebar.
- Page layout and branding.
- Current scope affecting first-screen permissions or empty states.

Non-first-screen queries do not need injection, such as dialogs, hover-triggered loads, later pagination, or refetches after user operations.

## Rendering Flow

### Request Phase

1. Phoenix route matches an SEO-supported page.
2. Controller performs permission checks, parameter parsing, and current user loading.
3. Controller synchronously fetches the data needed for the first screen.
4. Controller constructs `initial_state`.
5. Elixir renderer renders `root_html` using `initial_state`.
6. HEEx shell outputs head, assets, `#root`, and `window.__INITIAL_STATE__`.
7. Browser loads JS and enters React hydration.

### Browser Startup Phase

Startup logic should change from "always `createRoot`" to "hydrate when there is first-screen HTML, create otherwise".

```js
const initialState = window.__INITIAL_STATE__ || null
const store = createStore(initialState?.redux)
const queryClient = createQueryClient()

seedQueryClient(queryClient, initialState)

const root = document.getElementById("root")
const app = (
  <React.StrictMode>
    <Provider store={store}>
      <QueryClientProvider client={queryClient}>
        <App />
      </QueryClientProvider>
    </Provider>
  </React.StrictMode>
)

if (root.hasChildNodes() && initialState) {
  hydrateRoot(root, app)
} else {
  createRoot(root).render(app)
}
```

The startup entry needs to install a hydration warning collector before `hydrateRoot`, intercept hydration mismatch messages output by React through `console.error` / `console.warn`, and write them into `window.__HYDRATION_WARNINGS__`. If the collected result is non-empty, show a fixed tooltip in the bottom-right corner of the page with the warning count and the first summary, so manual acceptance checks can immediately notice drift. The tooltip is only for debugging and acceptance; it is not part of the SSR DOM contract.

First-screen-required state currently written into Redux by `useEffect` inside `App` should instead exist from the preloaded state at the beginning. Otherwise, React's first render sees "not loaded", while Elixir HTML already saw "loaded", easily causing mismatch.

## Elixir Renderer

The Elixir renderer does not execute React. It synchronously outputs first-screen DOM according to the page contract.

Recommended organization:

- `CatWeb.WebSSR.InitialState`: constructs `initial_state` by route.
- `CatWeb.WebSSR.Renderer`: dispatches page renderers according to `initial_state.page.kind`.
- `CatWeb.WebSSR.QueryKeys`: centrally maintains Elixir-side queryKey output.
- `CatWeb.WebSSR.HTML`: utilities for HTML escaping, attribute escaping, boolean attributes, class joining, and similar work.
- `CatWeb.WebController`: only handles route dispatch, fallback, and calling SSR modules.

Each page renderer is responsible for outputting first-screen HTML, not for async requests.

### File Naming Correspondence

For pages participating in first-screen hydration, React-side and Elixir-side files must exist in pairs, with aligned names:

- React: `web/src/main/pages/<Name>.ssr.jsx`
- Elixir: `lib/cat_web/web_ssr/pages/<name>.ex` (snake_case corresponds to the React filename)

CI should run a simple script that asserts the filename sets on both sides are equal. Missing either side fails the build. The purpose of this rule:

- Adding an SEO page forces both sides to be implemented at the same time.
- Deleting an SEO page forces both sides to be cleaned up, leaving no orphan renderer.
- Renaming is completed by renaming both files plus CI validation, avoiding silent drift.

Non-hydration pages continue to use the SPA fallback and do not need to follow this naming rule.

### Derived Computation Recommendation

To reduce the area of possible drift, it is recommended to compute JSX-derived values such as `isOwner`, `canEdit`, `showEmptyState`, and `reviewCountLabel` when Elixir constructs `initial_state`, and put them into Redux state or the corresponding query data. Both renderers then directly read the fields instead of repeating the computation inside templates.

This is a soft recommendation, not a contract:

- It is also acceptable for both sides to write the same if/else logic, with code review + DOM comparison tests as the safety net.
- The goal is to reduce the drift surface from "any JSX branch" to "`initial_state` field definitions".
- Whether to apply this depends on the branch complexity of each page. Simple pages do not need field extraction for this.

## React Reference Renderer

Keep a Node-side reference rendering interface during development. It is not the production rendering path.

Flow:

1. Elixir constructs `initial_state`.
2. The same `initial_state` is sent to the Node reference renderer.
3. Node creates the Redux store with `initial_state.redux`.
4. Node seeds the QueryClient with `initial_state.queries`.
5. Node runs React `renderToString` and obtains the reference `root_html`.
6. The Elixir renderer outputs `root_html` using the same `initial_state`.
7. Compare the two HTML outputs or DOM structures.

Prefer DOM structure comparison rather than byte-for-byte comparison. Reasons:

- Attribute order may differ.
- HTML serialization details may differ.
- Elixir may insert comments for debugging, which would create false positives in byte-for-byte comparison.

If byte-for-byte comparison is needed, limit it to the root HTML before comment insertion.

## Routing Strategy

Use a whitelist to support SEO hydration:

- `/web/subjects/:subjectId`
- `/web/posts/:postId`
- `/web/users/:idname`
- `/web/doulists/:doulistId`
- `/web/pages/:slug`
- Later, `/web/search`, `/web/tags/:tagIdname`, and `/web/rankings/:idname` can be added.

Ordinary pages continue to use the SPA fallback:

- `/web/*` routes without a renderer return an empty `#root`.
- React uses `createRoot`.
- Existing client-side data loading logic remains usable.

`/web/admin/*` does not enter this plan.

## Development Environment

In the current development environment, `/web/` is proxied to Vite, and HTML is returned by the Vite dev server. To debug Elixir first-screen HTML, development routing needs to be adjusted:

- Page HTML is returned by Phoenix.
- JS/CSS, HMR client, `/web/src`, and `/web/node_modules` continue to proxy to Vite.
- A switch can be kept to control whether the Elixir SSR dev path is enabled.

This makes it possible to verify locally during development:

- Whether no-JS HTML is complete.
- Whether `window.__INITIAL_STATE__` is correct.
- Whether hydration warning count is 0.

## Validation Criteria

Each page supporting SEO hydration must validate:

- With JS disabled, the HTML contains the first-screen core content.
- Browser console has no React hydration warnings.
- The hydration warning collector can collect mismatch information; if warnings exist, a tooltip appears in the bottom-right corner of the page.
- Events bind normally after hydration.
- The `initial_state` used by Elixir is consistent with `window.__INITIAL_STATE__` read by the browser.
- Redux preloaded state is consistent with the first-screen DOM branches.
- TanStack Query cache hits during the first render, without loading DOM replacement.
- After page navigation, React Router takes over normally.
- Client refetch does not break page state.
- Client-only UI appears only after mount.

Recommended Playwright tests:

- Intercept console error/warning.
- First-screen screenshot.
- Disable JS and assert fetched HTML.
- Click key buttons to confirm events are bound.

## Risks and Constraints

### DOM Drift

The biggest risk is long-term drift between the Elixir renderer and React components. A Node reference renderer or DOM structure tests must be used as a safety net.

### Query Key Drift

If the queryKey injected by Elixir differs from the queryKey used by React `useQuery`, the first hydrated render will enter loading again. queryKeys must be centralized.

### Time and Random Values

Relative time, current date, random ids, and random sorting can all cause mismatch. For the first screen, these should either be fixed by `initial_state` or delayed until after client mount.

### Permission Branches

Login state, two-factor status, ban status, and email verification banners all affect first-screen DOM. They must come from the same `currentScope` preloaded state.

### Immediate React Query Refetch

If a seeded query is immediately judged stale, it will refetch right after hydration and change the DOM. To avoid this, all of the following must hold:

1. Every first-screen query goes through a query factory, so `useQuery` and seeding share the same `staleTime`, with no secondary override at the call site.
2. `initial_state.queries[].updatedAt` comes from the moment Elixir finishes fetching data, not 0, not a constant, and not the template render time.
3. First-screen query `staleTime >= 30s`, and greater than the estimated SSR -> hydration gap.
4. `refetchOnWindowFocus` / `refetchOnReconnect` are explicitly declared in the factory and do not depend on the global default.
5. Naturally uncacheable data, such as real-time counters, random recommendations, or personalized ordering, does not enter the SEO hydration set and uses client fetch instead.

### Maintenance Cost

This is "two renderers, one shared contract", so its maintenance cost is higher than a pure SPA. It should only cover public pages that truly need SEO.

## Relationship with web2

The project already has `/web2/*`, which also follows the direction of Phoenix first-screen HTML + initialization data, but its renderer is not React hydration. It is Liquid + client-side local interaction.

This plan and `/web2` are two different implementations and do not depend on each other:

- This plan keeps the existing React component system under `/web/*`, letting React take over Elixir first-screen HTML in the browser.
- `/web2` is another independent SEO rendering path.

Choosing between the two paths in the future is not decided in this plan. The two lines can coexist temporarily before the final choice is made, but this plan itself only describes the `/web/*` hydration path.

## Phased Rollout

### Phase 1: Infrastructure

- Add `createStore(preloadedState)`.
- Add `seedQueryClient(queryClient, initialState)`.
- Make `main.jsx` support `hydrateRoot`, falling back to `createRoot`.
- Add a hydration warning collector and bottom-right tooltip.
- Define the `window.__INITIAL_STATE__` schema.
- Define centralized queryKey conventions.
- Adjust the development environment so Phoenix can return `/web` page HTML.

### Phase 2: Single-Page Pilot

Start with `/web/pages/:slug` or `/web/subjects/:subjectId`.

- Construct Elixir `initial_state`.
- Output first-screen `root_html`.
- Inject Redux and Query initial data.
- Add Node reference renderer comparison.
- Add Playwright hydration warning tests.

### Phase 3: Expand Public SEO Pages

Expand gradually by value:

- Subject detail.
- Post detail.
- User home page.
- Doulist detail.
- Static pages.
- Tags, rankings, search, and other list pages.

Each page must clearly define first-screen query coverage and client-only boundaries.

### Phase 4: Consolidate Standards

- Write the renderer contract into the page development guidelines.
- When adding a new page, declare whether it supports SEO hydration.
- Run DOM comparison and hydration warning tests in CI.
- Record accepted diffs to avoid accidental drift.

This is essentially a way to insert AI into the program compilation pipeline and use it as a form of human compilation: compiling React’s rendering logic into an Elixir version. This approach is genuinely interesting.

1 Like

So, one cannot compile your project without access to the same AI engine with sufficient token budget? And how long does it take?

Previously, people write the rendering logic twice. it sucks, but at least it is deterministic and when the work is done it is done.

1 Like

Now the idea is just to have AI write the rendering logic twice. It is not part of the compile step.

And yes, I agree: it is definitely less deterministic than having a human write it.