Porting an app with an existing frontend
Sometimes the job is not "build a GoFastr app" but "move an existing app
onto GoFastr without breaking its DOM contract" — a frontend whose tests
pin attributes like data-slot="card-title", whose CSS is a compiled
design system, whose markup the framework's components will never emit.
That is possible today, and this page shows the pieces. It is also
deliberately not a supported use case: the framework has one styling
surface (framework/ui + the theme tokens), and everything on this page
steps outside it. The contracts pipeline, the design system, and the
upgrade notes make no promises about markup and stylesheets you bring
yourself. You own the drift.
A screen is anything that returns render.HTML
There is no special template feature to enable. A screen component's
render is a render.HTML string; executing an html/template into one
is plain Go:
var page = template.Must(template.New("invoice").Parse( `<article data-slot="invoice"><h1>{{.Title}}</h1></article>`)) var buf bytes.Buffer if err := page.Execute(&buf, struct{ Title string }{"March"}); err != nil { panic(err) } // buf.String() is the screen's markup, verbatim.
Embed .tmpl files instead of inline strings — the markup stays
reviewable as markup, and designers can edit it without touching Go:
import ( "embed" "html/template" ) //go:embed templates/*.tmpl var templatesFS embed.FS var pages = template.Must(template.ParseFS(templatesFS, "templates/*.tmpl")) // pages.ExecuteTemplate(&buf, "invoice.tmpl", data) as above.
The executed HTML renders inside the App layout like any other screen, so navigation, sessions, and auth all still work.
html/template escapes interpolated data contextually; that part of the
safety story survives the port. What does not survive: none of the
framework's UI guarantees (theming, dark mode, a11y patterns, the
runtime's island wiring) apply to markup it did not emit.
Serving the foreign design system
A compiled stylesheet, fonts, and static JS mount without adopting any
of uihost:
static.Mount(app.Router(), static.Config{FS: assets, Prefix: "/static"})Reference them from your templates like any static asset. See
core packages for core/static's cache headers and
fingerprinting.
Where the line is
- Data layer, auth, routing, jobs, CRUD APIs: fully supported — nothing on this page changes how the backend works.
- Attributes on framework components: supported. If you only need extra
data-*/ ARIA attributes on components you otherwise adopt, useExtraAttrs(see UI getting started) — that path IS covered by the contract and its tests. - Bespoke markup + bespoke CSS via this page: possible, unsupported.
gofastr verifywill flag bespoke-CSS and inline-style patterns in app code; keep ported surfaces in clearly separated packages so the findings stay legible, and expect to re-verify the frontend yourself on every framework upgrade.
If a port later migrates surfaces onto framework/ui, each migrated
screen rejoins the supported world one at a time — the two styles can
coexist during the transition because both are just render.HTML.
Keeping a page's own scripts alive
A ported page whose behavior is built at script load breaks under soft navigation: the runtime swaps the content without re-running the destination's scripts, so every handler the old page bound is gone. Two opt-outs, at different grains.
Screen.NoSPA excludes a route from the client route manifest, so the
runtime treats it as unknown and every link to it is a full document
load:
legacy := app.NewScreen("/reports", &ReportsScreen{}) legacy.NoSPA = true site.RegisterScreen(legacy, appLayout)
data-cui-nav="off" does the same for one link, when the destination is
otherwise a normal SPA route:
ui.Link(ui.LinkConfig{Href: "/reports", Text: "Reports", ExtraAttrs: html.Attrs{"data-cui-nav": "off"}})
Reach for the screen-level form when the page can never be soft-loaded; the per-link form when one entry point is special.
Rendering a screen from a route you own
UIHost.PageHandler(path) returns an http.HandlerFunc that renders a
registered screen as a full page, chrome included, from a route mounted
on the framework router:
r.Get("/legacy/{path...}", site.PageHandler("/legacy"))Use it when a wildcard subtree claims a path the normal screen dispatch
never resolves — the mux redirects the bare path to a trailing-slash
form, and the NotFound screen answers instead of the screen you
registered. A dynamic pattern (/thing/{id}, or the /thing/:id
spelling a registered screen's Path holds) is passed through rather
than forced, so param capture still reads the real request path.
Answering with a status other than 200
A screen whose route resolved but whose record is gone should render a
body and say 404. Implement ScreenStatusCode:
func (s *ThingScreen) ScreenStatusCode() int { if s.thing == nil { return http.StatusNotFound } return 0 // 0 or 200 keeps the default }
The body still renders through the layout, so the user gets the real not-found page while crawlers and clients get the real status.
A component that only learns the answer while it draws sets the status
from its render instead, with app.SetStatus (core-ui/app):
func (c *ThingCard) RenderCtx(ctx context.Context) render.HTML { thing, ok := c.lookup(ctx) if !ok { app.SetStatus(ctx, http.StatusNotFound) return notFoundBody(ctx) } return drawThing(ctx, thing) }
The entity views do this already: entityui Record and a Create
duplicating a row answer 404 when the row is missing or hidden from the
caller, so a generated record screen and an admin record page need no
code for it. The first SetStatus in a render wins. A screen's own
ScreenStatusCode outranks it, and a 404-outlet outcome outranks both.
It applies on the full page, a client navigation, a drawer and
RenderScreen, and on the markdown a request with
Accept: text/markdown gets under WithMarkdownNegotiation. A dynamic
route's llm.md for such a page answers 404, the way it does when
Load returns app.ErrNotFound. A static export (gofastr export) skips a page that
answers anything but 200, with a warning, the way it skips a 404
outlet. The embed content route keeps answering 200 with the body the
screen drew: the embed loader mounts only a 2xx, and anything else
shows its "could not load" state instead. Outside a page render (an
island RPC, a component rendered on its own) the call does nothing.
A drawer opened onto a missing record falls back to an ordinary navigation: the runtime mounts an overlay only from a 2xx, so the reader lands on the record's own URL and sees its 404 page. A 404 or 410 drawer answer also drops the record's cached page first, so the fallback fetches the 404 instead of replaying the record from before it was deleted.
Common mistakes
- Inline
template.Muststrings scattered through Go files. Embed.tmplfiles withgo:embed+template.ParseFS. One real port accumulated 40 files of inline strings; the markup became invisible to every markup tool. - Expecting framework/ui components to emit a foreign DOM. They
emit their own DOM and always will;
ExtraAttrsattaches attributes, it does not reshape markup. If the test suite pins someone else's structure, render that structure from templates instead. - Adopting half of
uihostfor the styling. If the port brings its own design system, serve it withstatic.Mountand skip the framework's styling surface entirely on those screens — mixing the two on one surface means neither owns the result. - Treating this page as the recommended path. It is the escape
hatch. A new app, or a port free to change its DOM, should build on
framework/uiand get theming, dark mode, and a11y for free.