Remote pages¶
Most of a SynQt client is compiled into one WebAssembly bundle, which is the right default: the bundle downloads once, is cached, and every view renders at native QML speed. But a bundle has two costs that grow with it:
- Weight. Every view a visitor might reach ships to every visitor on first load, whether they open it or not.
- Release cadence. Changing any view means rebuilding and redeploying the whole client, so a page that changes often forces frequent releases of the whole bundle.
A remote page avoids both. It is a QML file the web edge keeps and delivers on demand, over
the client's authenticated wss link, when a visitor navigates to its route. It never
enters the bundle, so it adds nothing to the first load, and you edit it on the edge, so it
changes without a client rebuild. Use it for a campaign page, a seasonal landing page, or a
rarely visited legal notice.
This page is the feature's reference. Routes and URLs covers how a path resolves to a page of either kind. The light storefront tutorial builds a shop that uses remote pages: build it writes it, lighter and live has the hands-on checks, and links that work covers the URLs.
Declaring a remote route¶
A route in synqt.yaml is either compiled in or delivered by the edge, and its key
decides which. A view: route names a QML file in the client entity's directory, which
synqt build compiles into the bundle. A remote: route names a QML file in the edge's
pages/ directory, which the edge delivers when the visitor navigates.
routes:
- path: /
view: Home.qml # compiled into the client bundle
- path: /c/:campaign
remote: Campaign.qml # delivered by the edge, from web/edge/pages/Campaign.qml
seed: web/edge/campaign-seed.qml
synqt check refuses a route that sets both keys. Everything else works the same: a
remote: route takes a path with parameters, an optional scope: and the same fallback
behavior. It resolves through the same Router, and the
single Loader bound to Router.pageComponent renders it like a compiled-in view. The
client's QML never checks where a page came from.
Where the files live¶
A remote page lives in the web edge entity's pages/ subdirectory; for an edge named
edge, that is web/edge/pages/. The remote: value is the file's path relative to that
directory, so remote: Campaign.qml names web/edge/pages/Campaign.qml.
This directory is edge code, not client code. It is never compiled into the bundle and
never reaches a visitor who does not navigate to its route. synqt check resolves each
remote: file under <edge>/pages/ and refuses a route whose page is missing.
The palette: what a delivered page may import¶
A compiled-in view is trusted because it went through synqt build with the rest of your
code. A delivered page arrives at run time, and the client's QML engine interprets it on
the visitor's machine, so the client must limit what it can reach.
router.palette sets that limit: it lists every QML module a delivered page may import.
With this palette, a delivered page may import QtQuick and import QtQuick.Layouts, and
nothing else; the client refuses to render a page that imports any other module. The
palette is a trust boundary: what an edge could reach through a delivered page is exactly
the modules you allow. Keep it as small as your pages allow. synqt check refuses a
project with a remote: route and an empty palette, and a delivered page that imports a
module the palette does not list.
The build time check catches mistakes early. At run time, the client's QmlPalette
enforces the palette more strictly: it strips comments first and refuses any quoted (path)
import. A page the build time scan misses is still refused by the client, when the visitor
navigates.
The client reads a page exactly as the QML engine's lexer does, because any difference
would give a page somewhere to hide an import. It removes comments and string literals
first, ends a statement at a semicolon as well as at a line break, and honors every line
terminator the engine does (a lone carriage return ends a line; a leading byte order mark
is skipped). Then the word import may not appear anywhere the check has not approved. An
import it cannot account for is refused, however it got there, so the boundary does not
depend on foreseeing every way to write one.
The palette limits what a page declares. A page's JavaScript can still build QML at run
time (Qt.createQmlObject takes a string, imports included), and the scan does not read
strings. The model accounts for that: only your own edge can send a page, and an edge
willing to send a hostile page could send a hostile bundle.
Security says the same about the client
accessors a page can reach. The palette limits what an honest page can reach, and leaves
the edge itself trusted.
The page seed: painting the first frame¶
A delivered page arrives, is parsed and starts rendering before any connect point replica has pushed a value, so its first frame would be empty. The page seed fixes this: a small piece of data the edge computes per request and gives the page, so it paints real content at once.
A seed is a QML hook file that derives from SynQt.PageSeed and defines one function,
seedFor. Point a route at it with seed:, relative to the project root:
The hook itself, from
examples/stall/web/edge/campaign-seed.qml:
import SynQt
PageSeed {
function seedFor(route, parameters, caller): var {
const slug = parameters.campaign ?? "";
const words = slug.split("-").filter(part => part.length > 0);
const headline = words
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
return { headline: headline.length > 0 ? headline : "Today's offers" };
}
}
The edge runs seedFor once per fetch, after the route's scope check passes. It receives
the matched route, the captured path parameters and the caller, so it can tailor its
output to the request and the caller. What it returns becomes Router.pageSeed on the
client, a read-only map the page binds to. The stall's Campaign.qml reads
Router.pageSeed.headline, so /c/summer-sale shows "Summer Sale" on its first frame,
before the catalog replica arrives.
The seed is keyed on the actual parameters, not the page file, so two slugs get two seeds
even though one Campaign.qml serves both. When the visitor already holds the page body
(the content hash matches), the edge still sends a fresh seed, so a revisit with new
parameters paints the new data.
Important
Leave seedFor's parameters untyped. The edge calls the hook generically, passing every
argument as a QVariant. A typed parameter, such as seedFor(route: string, ...),
changes the QML method signature so the edge's call cannot match it, and the page
arrives with no seed. The edge detects this when it loads the hook, not per request,
and logs SynQt: page seed hook <file> declares seedFor with typed parameters; the edge
calls it with untyped (QVariant) arguments, so leave seedFor's parameters untyped or the
page is delivered with no seed. The browser shows nothing, so watch the edge log. You
may annotate the return type as : var, which matches, because a seed is a plain
object. The comment in
examples/stall/web/edge/campaign-seed.qml
documents this.
The edge-served route table¶
remote: routes stay out of the compiled client, unlike view: routes. The edge holds
them and sends its route table when the client connects, so the client learns from the
edge which paths it delivers. You can therefore add a new remote: route and reach it
without touching the client: the edge picks it up, tells the connected client, and the
client can navigate there.
When the two tables merge, the compiled-in one wins: a path the bundle declares as a
view: stays even if the edge announces a remote: at the same path, so the edge can
never shadow a compiled-in page. synqt check also refuses a remote: route whose path
collides with a compiled-in one, so the conflict is caught at build time, not silently
resolved at run time.
What a remote page does and does not protect¶
A scope: on a remote route protects the page. The edge checks the caller's scope before
delivering a single byte, and a refusal carries no markup, no content hash and no seed (see
security), so a visitor below that scope
cannot even obtain the page's QML. The stall's
Members.qml
has scope: user: an anonymous fetch comes back forbidden, and the file is never sent.
That protects the page, not the data it reads. When a delivered page reads a connect point,
the same owner side scope checks apply as for any other consumer. As everywhere in SynQt,
the owner side check on the connect point protects your data, and remote pages change
nothing about that. Use a page's scope: only to keep its markup off machines that should
not render it, never to hide data.
The interpretation cost¶
qmlcachegen compiles a compiled-in view ahead of time; the QML engine interprets a delivered page when it arrives. Each unique page is parsed once (the result is cached by content hash and reused on the next visit), so for a page the visitor opens and reads it is a one-time cost, while the first frame is already painted from the seed. That suits a campaign page, a landing page or a legal notice.
It does not suit anything redrawn every frame. A game's play surface, a chart that redraws
each tick or an animation loop needs ahead-of-time compiled QML: put it in the bundle as a
view:.
When not to use a remote page¶
- The view is on a hot path or animates every frame. Compile it in.
- The view is central and every visitor reaches it. Delivering it saves no weight and adds a fetch. Compile it in.
- You want to hide data. A remote page protects markup only. Put a
scope:on the connect point that carries the data, as for any view.
Use a remote page for a view that is secondary, changes on its own schedule, or is reached by few visitors.