SSR pages and routing
Render application pages on the server and map them to explicit routes.
An SSR page loads authorized data and returns a synchronous SolidJS render function.
Render a page
import { Layout } from "@k2b/cloud/ssr";
import type { AuthContext } from "@k2b/cloud/server";
import { ssr } from "../config";
export default ssr<AuthContext>(async (c) => {
const accessSubject = c.get("accessSubject");
const url = new URL(c.req.url);
const items = await inventory.list({
accessSubject,
search: url.searchParams.get("search") ?? undefined,
});
c.get("page").title = "Inventory";
return () => (
<Layout c={c} title="Inventory">
<InventoryPage items={items} />
</Layout>
);
});Load data, redirect, and set metadata before the returned function.
A page deeper in the application passes the pages above it as a title array,
for example title={[{ title: "Users", href: "/app/accounts/users" }, { title: user.uid }]}.
That is all an application provides: the shell derives the back link, the
path, and One level up from it, and returns to the page above as the tab
left it. An application that draws its own page header places PageTrail
from @k2b/cloud/ssr/islands in it. See
Show the way up.
Every rendered page is sent with Cache-Control: private, no-store, so the
browser does not show a signed-in page from its HTTP cache on Back. A page that
should be cached sets its own header with c.header("Cache-Control", ...)
before returning; the framework keeps it. Redirects and other Response values
returned by the handler are sent as they are.
Browsers can still restore a page from their back/forward cache with its in-memory state. An island that saves state in the browser, such as a preference cookie, should write only the values the user changed, so a restored page or a second tab does not overwrite newer choices.
The returned function must be synchronous. Solid SSR creates JSX inside
renderToString().
The framework resolves the request locale into c.get("page").lang and the
document's <html lang> attribute for every SSR page. Layout, AdminLayout,
and MinimalLayout provide the same value to @k2b/ui components, and browser
islands inherit it from the document. Use MinimalLayout for an app-styled
standalone root that still needs Cloud's persisted locale, theme, and timezone
wiring; it ends the page with a shared legal and preferences footer. A
deliberately custom root that uses none of these layouts must wrap its returned
component tree once with LocaleProvider from @k2b/ui, using
getLocale(c). See Internationalization
before formatting or translating values in a page.
Authorize page data
An SSR page calls a service directly. API route middleware does not run for that call.
Pass accessSubject into the service and repeat every resource permission
check needed for the rendered data.
Use expectUserBackedActor(c) only when the page truly requires a user. A
resource-bound service account has no user.
See Request identity and Resource authorization.
Map routes explicitly
import {
type AuthContext,
auth,
} from "@k2b/cloud/server";
import { Hono } from "hono";
import detailPage from "./detail/page";
import listPage from "./page";
import { ssr } from "../config";
export default new Hono<AuthContext>()
.get(
"/",
auth.requireRole("user", ssr.access),
...listPage,
)
.get(
"/:id",
auth.requireRole("user", ssr.access),
...detailPage,
);The file tree does not create routes. Spread the middleware array returned by
ssr().
Register fixed routes before dynamic or catch-all routes.
Render page errors
Return ssr.error(c, status) when a whole page cannot be shown. It uses the
application's SSR template and the shared error state, with the request locale,
theme, a home link, and the actual HTTP status. It also sets
Cache-Control: private, no-store.
const detailPage = ssr<AuthContext>(async (c) => {
const result = await inventory.read({
id: c.req.param("id")!,
accessSubject: c.get("accessSubject"),
});
if (!result.ok) return ssr.error(c, result.error.status);
return () => <Layout c={c}><InventoryDetail item={result.data} /></Layout>;
});Use 403 for denied access and 404 for a missing resource. Preserve a
service's intentional existence-hiding 404; do not add a lookup to tell
missing and inaccessible resources apart. Other HTTP error statuses are
preserved and receive generic failure copy. This does not catch exceptions.
The optional third argument accepts title, description,
action: { label, href, icon? }, and layout: "cloud" | "minimal" | "pwa"
(default "cloud"). Supply only safe, localized application copy, never
internal error details. Use "minimal" for standalone pages without Cloud
navigation. "pwa" (preview) renders the error as a page of the mobile app,
with a link back to its Start page; it is for pages below /pwa/. A
custom public page can instead set c.status(404) and retain its own render
function. Keep independent widget or panel failures inside their page.
Add an explicit fallback after the known page routes, scoped to the page prefix:
router.get("/app/inventory/*", auth.requireRole("*"), (c) => ssr.error(c, 404));Do not use an application-wide fallback for mixed page/API routers. Register API, asset, download, and protocol boundaries before page fallbacks and retain their own non-HTML not-found behavior. A page fallback does not replace route authorization on existing pages.
Serve anonymous pages
Use an application-owned prefix such as /share/inventory. Add it to
defineApp().routes.
/public/<app> is reserved for generated static assets. Application pages
registered there are not reached.
Use auth.requireRole("*") when a page accepts both anonymous and signed-in
requests. That middleware does not grant resource access. Validate the share
token or public grant in the service.
Choose Layout when the page should retain recognizable Cloud navigation.
Choose MinimalLayout when the application owns the page's visual surface
without Cloud header or navigation; Cloud still adds the legal and preferences
footer described in
Layout and navigation.
Neither choice changes route or resource authorization.
Verify the page
Test the page route with and without a valid session. Verify denied data never appears in the HTML.
The page must remain correct on reload and without JavaScript. Islands are an enhancement, not the only rendering path.
One-time hints for moved paths
Render MovedHint directly after the old control. Decide visibility on the
server with hintOpen(c, "files.tags", { allowed }) from @k2b/cloud/ssr.
allowed is required: check whether the person may use the new path. The
helper also requires a user-backed request actor; anonymous visitors see no hint.
Pass that result to the island as open, so a dismissed hint never flashes
on reload. See MovedHint for keyboard and layout behavior.
Call markHintSeen(c, "files.tags") from @k2b/cloud/ssr in the new path's
route, after its access check: using the new path counts as seen. It appends a
Set-Cookie header and preserves other cookies. Behind the gateway,
x-forwarded-proto: https also sets Secure.
In the island's onDismiss, call markHintSeen("files.tags") from
@k2b/cloud/browser/hints before setting open to false or navigating.
The UI component owns no storage; these helpers share the cloud_seen_hints
cookie, scoped to / with SameSite=Lax and a one-year lifetime. Like
announcements, this is per browser, not an account preference. Clearing cookies
shows eligible hints again. Storage must be available to remember dismissal.
IDs match /^[a-z0-9][a-z0-9._-]{0,63}$/; invalid IDs throw. The cookie keeps
at most 64 unique IDs in recent-use order and drops the oldest. Long IDs can
reach the 4096-byte cookie budget earlier; the oldest IDs are dropped then too.
Use a stable,
application-qualified ID such as files.tags. Do not store personal data in IDs.