Skip to content

Build it

Goal: a small storefront whose product grid ships in the client bundle, and whose campaign pages the web edge delivers on demand. A merchandiser edits or adds a campaign, and it goes live without a client rebuild. The finished app is examples/stall; this page builds it step by step. If you arrived here directly, start with the overview, which creates the project.

The storefront has three entities: a browser client; a web edge that owns the live catalog and delivers the campaign pages; and a stock database holding the stock, which the browser reaches only through the edge. This page focuses on the campaign pages. The catalog and the database follow the pattern from the auction's Hall of Fame, so they get less space.

Step 1: Split the route table

A route is either compiled into the client bundle or delivered by the edge; its key decides which. Open synqt.yaml and write the table:

routes:
  - path: /
    view: Home.qml            # compiled in, the product grid
  - path: /cart
    view: Cart.qml            # compiled in, the cart

  - path: /c/:campaign
    remote: Campaign.qml      # edge-delivered, and one page serves every campaign slug
    seed: web/edge/campaign-seed.qml
  - path: /members
    remote: Members.qml       # edge-delivered, and members only
    scope: user

view: names a file in the client entity's directory. remote: names a file in the edge's pages/ directory. A route has one or the other, never both.

Step 2: Declare the palette

A compiled-in view went through synqt build with the rest of your code, so it is trusted. A delivered page arrives at run time, so the client must be told what it may import. router.palette is that list, and a delivered page can reach nothing else:

router:
  fallback: /
  base: /
  palette: [QtQuick, QtQuick.Layouts]

With this palette, a delivered page may import QtQuick and import QtQuick.Layouts. The client refuses to render a page that imports anything else. Keep the palette as small as your pages allow: it is a trust boundary (see the remote pages reference).

Step 3: Write the campaign page on the edge

A delivered page lives in <edge>/pages/; for an edge named edge, that is web/edge/pages/. Create web/edge/pages/Campaign.qml:

import QtQuick
import QtQuick.Layouts

// One file serves every slug. Its root is an Item, because a delivered page is loaded into
// the client's Loader rather than shown as a window of its own.
Item {
    id: campaign

    readonly property string headline: Router.pageSeed.headline ?? "Today's offers"

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 16
        spacing: 12

        Text {
            text: campaign.headline
            font.pixelSize: 24
            Layout.fillWidth: true
            wrapMode: Text.WordWrap
        }

        ListView {
            Layout.fillWidth: true
            Layout.fillHeight: true
            clip: true
            model: Server.offers
            delegate: Text {
                required property string title
                required property int price
                width: ListView.view.width
                text: title + "  -  " + price
            }
        }
    }
}

Two details matter. The root is an Item, not a window: the client loads a delivered page into its Loader through Router.pageComponent, so the page is a fragment, not a window of its own. And it imports only QtQuick and QtQuick.Layouts, the two modules the palette allows.

The page reads Router.pageSeed.headline, which comes from the seed, the next step.

Step 4: Seed the first frame

One Campaign.qml serves /c/summer-sale, /c/black-friday and every other slug. On its own it would show an empty first frame, before Server.offers has pushed anything. The page seed fixes that: it runs on the edge, per request, and hands the page data to paint at once. Create web/edge/campaign-seed.qml:

import SynQt

PageSeed {
    // The parameters are left untyped on purpose (see the callout below).
    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. It turns the slug into a headline (summer-sale becomes "Summer Sale"). Whatever it returns becomes Router.pageSeed on the client, so Campaign.qml paints the headline on its first frame. The route points at the seed with seed:, relative to the project root, as you wrote in step 1.

Important

Leave seedFor's parameters untyped. The edge calls the hook generically and passes 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 and paints empty. The edge detects this when it loads the hook and logs the cause: page seed hook ... declares seedFor with typed parameters ... leave seedFor's parameters untyped. The browser shows nothing, so watch the edge log. You may annotate the return as : var, which matches, because a seed is a plain object. The comment in web/edge/campaign-seed.qml says the same.

The compiled-in home page opens a campaign. In client/app/Home.qml, a button navigates to it like any other route:

Button {
    text: "See today's offers"
    onClicked: Router.go("/c/summer-sale")
}

Router.go("/c/summer-sale") is the same call whether the target is compiled in or delivered. The router resolves the path, sees a remote: route, fetches Campaign.qml from the edge over the same wss link, and hands the component to the Loader in client/app/Main.qml. Your client QML never checks where a page came from.

Step 6: Run it

Start the app with synqt dev, open the storefront, and click "See today's offers".

  • The page navigates to /c/summer-sale.
  • Its first frame shows the headline "Summer Sale", from the seed, before any offer arrives.
  • A moment later, the offers list fills in from Server.offers.

Now watch the network. The client fetches Campaign.qml from the edge the first time you open a campaign, and never again. The edge answers with a content hash, the client caches the page body under that hash, and a later visit to any /c/... slug served by the same Campaign.qml comes back notModified, with only the small per-request seed on the wire. The page arrives once, and the headline is fresh every time.

Try it, then think

Question

Add a members-only page. Create web/edge/pages/Members.qml with an Item root that imports only the palette modules. Its route has remote: Members.qml and scope: user (you wrote it in step 1). Sign out, then go to /members in the address bar. What does the edge send? Now sign in and try again.

Solution

Signed out, the edge refuses the page. It checks the route's scope: user against the session before delivering a single byte, and a fetch below that scope comes back forbidden, with no markup, no content hash and no seed. The file is never sent, so its source never reaches a machine that may not see it. Signed in as a user, the same fetch succeeds and the page renders.

As in the auction, the barrier is on the owner. Here the owner is the edge, and it checks before delivery. A route guard on the client only steers navigation; the edge's refusal is what keeps the page's markup off the visitor's machine.

Important

A scope: on a remote page protects the page's markup, not the data the page reads. Members.qml stays off an anonymous visitor's machine. But when any delivered page reads a connect point, the connect point's owner side scope check governs that read, exactly as for a compiled-in view. Hide data with the connect point's scope, never with a page's scope:. See security.

What you learned

  • A route is compiled in (view:) or delivered by the edge (remote:), never both. The key decides.
  • A delivered page lives in <edge>/pages/, never enters the bundle, and can be edited on the edge without a client rebuild.
  • router.palette is the trust boundary: every module a delivered page may import.
  • The page seed runs on the edge per request and paints the first frame, so a delivered page shows real content before its connect points arrive. Leave its parameters untyped.
  • A delivered page's scope: protects its markup, not its data. The connect point's owner side check protects data, as always.