Rendering modes
A Gyral component is the same code wherever it renders. What changes is when its HTML is made, and that is a choice you make per page, not per app:
| Mode | HTML is made | Needs at runtime | Use it for |
|---|---|---|---|
| Client | in the browser, by JavaScript | any static file host | widgets inside an existing page, admin UIs |
| Static | once, at build time | any static file host | home, docs, marketing, blog posts |
| Per request | on the server, for each visit | a server (Node, Bun, …) | carts, accounts, search results, forms |
The static and per-request modes produce the same HTML: a static page is a per-request page rendered ahead of time. Both work before any JavaScript loads and hydrate in place when it does, so you can move a page from one mode to the other without touching its components.
Mixing modes
Most real sites mix them. This website prerenders every page and runs two live components: the counter on the home page and the search box on the search page. gyral-shop renders product listings and the cart per request, because they change with every visitor.
Keep the decision next to your routes, so there is one place to read it:
// src/routes.ts
import { routes } from '@gyral/router';
import type { RenderMode } from '@gyral/ssr/static';
export const site = routes({ home: '/', about: '/about', account: '/account' });
/** How each route renders in production. */
export const modes = {
home: 'ssg',
about: 'ssg',
account: 'ssr',
} as const satisfies Record<'home' | 'about' | 'account', RenderMode>;
/** Every route rendered at build time, for the prerender step. */
export const staticPaths = (): string[] =>
(Object.keys(modes) as (keyof typeof modes)[])
.filter((name) => modes[name] === 'ssg')
.map((name) => site.href(name, {}));RenderMode names them the way the rest of the ecosystem does: ssg (static), ssr (per
request) and csr (client). Gyral's
isomorphic example uses
exactly this table.
Client-only builds
An app that no server renders can tell the build so, and leave the hydration code out of its bundle:
// vite.config.ts
import { defineConfig } from 'vite';
import { gyral } from '@gyral/core/vite';
export default defineConfig({
plugins: [gyral({ clientOnly: true })],
});With clientOnly: true the browser bundle carries no hydration code: no seed reading and no
hydration chunk, and when your app has no other import(), Vite's preload helper goes too. The
fallback for invoker commands stays only when a module may make
a component listen for command intents (what the build reads).
Measured on Gyral's examples (gzip, 0.3.10):
| App | Default: first load | Default: all chunks | clientOnly: true |
|---|---|---|---|
| hello-world | 9.70 KiB | 12.15 KiB | 8.60 KiB |
"First load" is what a page downloads before any lazy import(); in these examples a
client-only build has nothing lazy left, so its first load is all of it.
Use it for apps that load their components with a <script type="module"> into pages no Gyral
server wrote: widgets in an existing site, admin tools, single-page apps. Leave it off as soon
as any page is server-rendered or prerendered, including islands, and give your Vitest config
the same gyral({ clientOnly: true }) so tests build the way the app does. List plugins of
your own beside it: plugins: [gyral({ clientOnly: true }), mine()].
If server-rendered markup reaches a client-only build anyway, nothing breaks twice: each
component drops the server's seed and DOM and renders fresh from its attributes, so a view is
never doubled. Development warns once and names clientOnly; production renders fresh
silently. The server's work is wasted, though, so treat that warning as a configuration bug.
Starting from create-gyral
create-gyral's basic template is client-only from the start: its vite.config.ts sets
gyral({ clientOnly: true }), and its vitest.config.ts reuses that config, so tests
build the way the app does. A new app is about 8.5 KiB gzip, in one file. Keep the option while
every page renders in the browser. Delete clientOnly: true as soon as any page is rendered on
a server or prerendered, for example when you add @gyral/ssr: with it on, those components
can't hydrate and render again from scratch. The ssr template builds without it.
What the build reads
To decide what a bundle needs, the build reads the code that can affect your components, not your whole dependency tree:
- Which modules: your own source (everything outside
node_modules, workspace and linked packages included), and installed packages that are Gyral packages or list a@gyral/*package independencies,peerDependenciesoroptionalDependencies, directly or through their own dependencies, such as a design system built on Gyral. Any other package can't define a component or write its spec, so it isn't read. - What counts: the parsed source, not its text, so comments and type-only code never count.
The invoker fallback stays when a module's markup has
commandin adata-intent-onlist (data-intent-on="command"), adata-intent-commandattribute or a bounddata-intent-on, when it has the string'command'on its own (asetAttribute('data-intent-on', 'command')), or when it usesrawimported from@gyral/core(under any alias, or through a module that re-exports it), whose markup is only known at run time. A function of another package that happens to be calledrawdoesn't count.
Every build with gyral(), client-only or not, uses the same scan for the features a spec
field turns on: view transitions, the frame lane, custom states, form association,
subscriptions and the error fallback are bundled only when a module names viewTransition,
renderOnFrame, states, formAssociated, subscriptions, error or Errored in code.
Write these field names literally: a name built at run time isn't seen, and the component then
renders without that feature (development builds warn). A package that writes these fields for
your components must depend on Gyral to be read: a component library lists @gyral/core as a
peer dependency in its package.json
(Publishing a component library).
This site server-renders its pages and hydrates two islands, so it builds without the option.
Where to go next
- Client only: write components as in Getting started and load
them with a
<script type="module">. No server code at all, and a client-only build to match. - Static: Static sites and prerendering.
- Per request: Server rendering.
- Shipping it: Deploying covers static hosts, Node, Bun and edge runtimes.
- A Rust backend: Rust servers covers each mode with Rust as the web server.
- Less JavaScript on each page: Code-splitting and lazy loading.