Getting started
Coming from SvelteKit
Tern keeps SvelteKit's model of file-based routes, loads, form actions, layouts and progressive enhancement. Your Svelte components move over almost unchanged. What changes is the server side: loads, actions, hooks and endpoints are Go, and the app ships as one Go binary with no Node in production.
This page maps what you know to Tern. The guide has the details.
#Files and routes
| SvelteKit | Tern |
|---|---|
src/routes/+page.svelte, +layout.svelte, +error.svelte |
the same |
+page.server.ts with load |
any .go file in the directory, with func Load(ctx *kit.Ctx) (Data, error) |
+layout.server.ts with load |
func LayoutLoad(ctx *kit.Ctx) (Data, error) |
+page.ts / +layout.ts (universal load) |
not needed: data comes from Go; fetch in onMount for client-only data |
+server.ts with GET, POST, … |
func GET(ctx *kit.Ctx) error, or a typed endpoint func GET(ctx *kit.Ctx, in In) (Out, error) |
[slug] |
slug.param |
[[lang]] |
lang.optional |
[...path] |
path.rest |
(group) |
app.group |
[id=integer] (matcher) |
id.param.integer, with matchers registered by kit.WithMatcher |
+page.md with mdsvex |
+page.md, built in; collections with the content package |
Directory names change because Go import paths can't contain brackets or parentheses. Links don't change: route('/blog/[slug]', { slug }) from $routes builds /blog/hello, and TypeScript checks the pattern exists.
#Loading data
// SvelteKit: +page.server.ts
export const load = async ({ params, locals }) => {
const post = await db.post(params.slug);
if (!post) error(404, 'Not found');
return { post };
};
// Tern: page.server.go in the same directory
func Load(ctx *kit.Ctx) (Page, error) {
post, err := db.Post(ctx, ctx.Param("slug"))
if errors.Is(err, sql.ErrNoRows) {
return Page{}, kit.NotFound()
}
return Page{Post: post}, err
}
- Types: the returned struct's TypeScript type is generated.
PagePropsfrom./$typesworks as in SvelteKit. - Parent data:
await parent()becomeskit.Parent[LayoutData](ctx). - Errors and redirects:
error(404, …)becomeskit.NotFound()orkit.Error(status, message).redirect(303, '/login')becomeskit.Redirect(http.StatusSeeOther, "/login"). - Streaming: promises in returned data become
kit.Defer(func() (T, error) { … }). - Reruns:
depends('app:x')andinvalidate('app:x')becomectx.Depends("app:x")andinvalidate('app:x'). Layout loads rerun only when what they read changes, as in SvelteKit.
#Form actions
var Actions = kit.Actions{
"default": kit.Action(func(ctx *kit.Ctx) (Saved, error) {
form, err := ctx.Form()
if err != nil {
return Saved{}, err
}
if form.Get("email") == "" {
return Saved{}, kit.Fail(400, Invalid{Error: "Enter your email."}) // fail(400, …)
}
return Saved{OK: true}, nil
}),
}
use:enhance, form props, named actions (?/name) and applyAction work as in SvelteKit.
#Hooks, locals and cookies
| SvelteKit | Tern |
|---|---|
handle in hooks.server.ts |
kit.WithHook(func(ctx *kit.Ctx) error), or kit.WithMiddleware for net/http middleware |
event.locals |
ctx.Locals |
cookies.get / cookies.set |
ctx.Cookie / ctx.SetCookie |
setHeaders |
ctx.SetHeader |
handleError |
errors are logged with the request; customize +error.svelte |
hooks.client.ts |
beforeNavigate, onNavigate and afterNavigate from @ternkit/tern |
CSRF protection, security headers, a Content-Security-Policy and a rate limit on writes are on by default (docs/security.md); sessions, CORS, metrics and health checks are plugins.
Where a SvelteKit app checks the user in handle or in each load, a Tern route declares who may use it: var Access = kit.SignedIn in its package, which covers the routes below too. A route with server code and no rule doesn't build (who may use a route).
#Client modules
| SvelteKit | Tern |
|---|---|
$app/state: page, navigating |
page, navigating from @ternkit/tern |
$app/navigation: goto, invalidate, invalidateAll, preloadData, beforeNavigate, afterNavigate, onNavigate |
the same names from @ternkit/tern |
$app/forms: enhance, applyAction |
@ternkit/tern |
$env/static/public, $env/dynamic/public |
env from @ternkit/tern, with PUBLIC_ variables from the server's environment |
$env/*/private |
os.Getenv in Go |
$app/paths: base, resolve |
route() from $routes, which includes the base path |
$service-worker |
the same |
$lib |
the same |
#Configuration and deployment
svelte.config.js: no adapter.vite.config.jsusestern()from@ternkit/tern/vite, and the few settings Go and Vite share (base path, i18n, service worker) live in an optionaltern.json.- Prerendering:
export const prerender = truebecomesconst Prerender = true.entries()becomesfunc Entries(ctx *kit.Ctx) ([]map[string]string, error). - Turning off SSR:
export const ssr = falsebecomesconst SSR = false. - Building:
tern buildproduces one static binary with the client, the server renderer and your Go code. Run it anywhere: a container, a VM or a systemd unit. See deployment.
#Server rendering
Pages render on the server in a JavaScript engine embedded in the Go binary (QuickJS), not in Node:
- Web APIs:
URL,TextEncoder,crypto.randomUUID,structuredClone,AbortControllerand the other APIs libraries commonly use are there. Popular libraries are checked in CI. Node built-ins (fs,Buffer) are not. - Data fetching:
fetchis not available while rendering, because data comes from Go loads. Intl: formats likeen-USon the server. The browser re-renders with the user's locale.
#What Tern adds
These are built in, where SvelteKit leaves them to libraries:
- Data and background work: database migrations, background jobs and cron.
- Realtime: server-sent events and WebSockets.
- Auth: sign-in with OAuth providers, an OpenID Connect provider of your own, and roles.
- Content and assets:
- email with Svelte templates
- file uploads and storage, on disk or in S3
- responsive images
- translations
- APIs: OpenAPI for typed endpoints.
- Component logic in Go: live components run on the server, with state shared across visitors.
#What's different or missing
- Universal loads: no
+page.tsor+layout.ts, since data comes from Go. - Adapters: none. Tern always builds a Go binary. For static hosting, prerender every page.
- Shallow routing:
pushStateandreplaceStatefrom$app/navigationaren't available.goto(url, { state })andpage.statecover the common case. - Snapshots: not available.