Search titles and sections of every page. ↑↓ to move, ↵ to open

Getting started

Guide

Everything a Tern app is made of, from routes to realtime.

39 min read Edit on GitHub

This guide covers everything you need to build an app with Tern. If you know SvelteKit, most of it will feel familiar; the differences are called out.

#Project structure

my-app/
├── src/
│   ├── app.html            HTML template
│   ├── lib/                shared Svelte and TypeScript code, imported as $lib
│   └── routes/             your pages, layouts and server code
├── static/                 files served as-is (favicon, robots.txt, …)
├── main.go                 starts the server
├── go.mod                  pins the Tern CLI with a `tool` directive
├── package.json
├── tsconfig.json           extends .tern/tsconfig.json
└── vite.config.js          uses the tern() plugin

Tern generates tern.gen.go and the .tern/ directory. Both are gitignored and regenerated by tern dev, tern build and tern gen.

src/app.html must contain %tern.head% and %tern.body%. It may also use %tern.nonce%, the per-request nonce Tern puts on its own scripts, for example in a Content-Security-Policy <meta> tag.

#Routing

Every directory under src/routes is a URL segment. A directory becomes a page when it has a +page.svelte.

Directory URL
src/routes/ /
src/routes/about/ /about
src/routes/blog/slug.param/ /blog/:slug

Go import paths cannot contain brackets, so dynamic segments use suffixes instead of SvelteKit's notation. tern routes and error messages still show patterns in bracket notation.

Directory Pattern Matches
slug.param [slug] /hello
id.param.integer [id=integer] /42, but not /abc
lang.optional [[lang]] / and /en
path.rest [...path] /, /a, /a/b/c
app.group (none) nothing: groups organize routes without changing URLs

When several routes match a URL, the most specific wins: static segments beat parameters, parameters with a matcher beat those without, required beats optional, and rest parameters come last. Two routes that match exactly the same URLs are an error.

Matchers restrict a parameter. integer and uuid are built in; register your own in main.go:

ternApp(kit.WithMatcher("slug", regexp.MustCompile(`^[a-z0-9-]+$`).MatchString))

Route files. Svelte files that start with + define the UI; everything else in a route directory is yours.

File Purpose
+page.svelte the page
+layout.svelte wraps this directory's page and every page below it
+error.svelte renders errors for this directory and below
*.go the route's server code, an ordinary Go package

Colocated components, styles and Go helpers are fine. The route's Go package does whatever its exports say:

Export Role
func Load(ctx *kit.Ctx) (T, error) data for +page.svelte
func LayoutLoad(ctx *kit.Ctx) (T, error) data for +layout.svelte
var Actions = kit.Actions{…} form actions for the page
func GET(ctx *kit.Ctx) error (and POST, PUT, PATCH, DELETE, OPTIONS, HEAD) endpoint handlers
const SSR = false render this page on the client only
const Prerender = true render this page to static HTML at build time
func Entries(ctx *kit.Ctx) ([]map[string]string, error) parameters to prerender a dynamic route with; ctx is optional
const Revalidate = 300 render this page on its first request and serve it from the cache for 300 seconds (or a time.Duration; -1 until revalidated). See docs/caching.md
const CacheControl = "public, max-age=60" the Cache-Control header of this route's successful responses
const CSR = false send this page without JavaScript

By convention the code lives in page.server.go, layout.server.go and server.go, but file names do not matter. Because route directories are normal Go packages, go test ./..., go vet, gopls and linters work on them as usual.

#Who may use a route

A route with server code (a load, actions or an endpoint) says who may use it, in its package:

var Access = kit.Public                    // anyone
var Access = kit.SignedIn                  // a signed-in user
var Access = kit.Permission("links.edit")  // a signed-in user with the permission

Without one, the build stops and names the route: routes are closed by default (ADR 0027). kit.SignedIn and kit.Permission apply to the directories below too, so one in a (app) group covers every page in it, and a directory below can say otherwise, as a login page does with kit.Public. kit.Public covers its own directory only, so a route added later is never public by accident.

Tern checks the rule before the loads, actions and endpoints run, for the page, its data and its form posts alike. What a signed-in user is comes from the app: acc.Guard() from accounts, or kit.WithSignedIn(check) with a check of your own; policy.Guard() from rbac checks permissions. Signed out, pages redirect to sign in and endpoints answer 401. go tool tern routes lists every route's rule.

Upgrading an app from before v1.16, go tool tern fix access marks each route without a rule kit.Public, which keeps it as it was; then tighten the ones that should be.

#Loading data

A page's Load runs on the server for every request to the page, and for client-side navigations to it:

// src/routes/blog/slug.param/page.server.go
package post

import "github.com/Unfield/Tern/kit"

type Data struct {
	Title string `json:"title"`
	Body  string `json:"body"`
}

func Load(ctx *kit.Ctx) (Data, error) {
	post, err := db.PostBySlug(ctx, ctx.Param("slug")) // *kit.Ctx is a context.Context
	if errors.Is(err, sql.ErrNoRows) {
		return Data{}, kit.NotFound()
	}
	if err != nil {
		return Data{}, err
	}
	return Data{Title: post.Title, Body: post.Body}, nil
}
<!-- src/routes/blog/slug.param/+page.svelte -->
<script lang="ts">
	import type { PageProps } from './$types';
	let { data }: PageProps = $props();
</script>

<h1>{data.title}</h1>

The data type must encode to a JSON object: a struct, a pointer to a struct, or a map with string keys. It is encoded as encoding/json encodes it, so struct tags work as usual; nil slices are sent as []. Don't change what a load returns after it returns: the server renders the page from it.

*kit.Ctx gives you the request:

Method Returns
ctx.Param(name), ctx.Params() route parameters
ctx.URL(), ctx.Request the request URL and the full *http.Request
ctx.Header(name) a request header
ctx.Cookie(name) a request cookie
ctx.SetCookie(c), ctx.DeleteCookie(name) set cookies; Path=/, SameSite=Lax and Secure (except on plain-HTTP localhost) by default
ctx.SetHeader(name, value) set a response header
ctx.Locals request-scoped values, usually set by a hook
ctx.Form() the submitted form, in actions

#Layouts

A +layout.svelte wraps every page in its directory and below. It renders its page with children:

<script lang="ts">
	import type { LayoutProps } from './$types';
	let { data, children }: LayoutProps = $props();
</script>

<nav>Signed in as {data.user}</nav>
{@render children()}

A layout loads data with LayoutLoad in its directory's Go package. Every page below it receives that data merged into its own data, and layouts receive the data of the layouts above them.

All loads for a page run in parallel. A load that needs a parent layout's data calls kit.Parent, which waits only for the layouts above it:

type Parent struct {
	User string `json:"user"`
}

func Load(ctx *kit.Ctx) (Data, error) {
	parent, err := kit.Parent[Parent](ctx)
	if err != nil {
		return Data{}, err
	}
	return loadDashboard(ctx, parent.User)
}

A directory with a LayoutLoad but no +layout.svelte still contributes data; it just does not add markup.

#Errors and redirects

Return these errors from loads, actions, endpoints and hooks:

Error Response
kit.NotFound() 404, rendered by the nearest +error.svelte
kit.Error(status, message) that status; message is shown to the user
kit.Redirect(status, location) a redirect (use 303 after form posts)
any other error logged; the user sees 500 "Internal Error"
a panic recovered, logged, and treated like an unexpected error

+error.svelte reads the error from page:

<script lang="ts">
	import { page } from '@ternkit/tern';
</script>

<h1>{page.status}</h1>
<p>{page.error?.message}</p>

The error page renders inside the layouts above it that loaded successfully. Unexpected error messages are never sent to the browser in production; in tern dev they are, to help debugging.

#Form actions

Actions handle POST requests from forms on a page:

type Created struct {
	ID int `json:"id"`
}

type Invalid struct {
	Title string `json:"title"`
	Error string `json:"error"`
}

var Actions = kit.Actions{
	"create": kit.Action(func(ctx *kit.Ctx) (Created, error) {
		form, err := ctx.Form()
		if err != nil {
			return Created{}, err
		}
		title := strings.TrimSpace(form.Get("title"))
		if title == "" {
			return Created{}, kit.Fail(400, Invalid{Title: title, Error: "Title is required"})
		}
		return Created{ID: db.Create(ctx, title)}, nil
	}),
}
<script lang="ts">
	import { enhance } from '@ternkit/tern';
	let { data, form }: PageProps = $props();
</script>

<form method="POST" action="?/create" use:enhance>
	<input name="title" value={form?.title ?? ''} />
	<button>Create</button>
</form>
{#if form?.error}<p>{form.error}</p>{/if}
  • A form without ?/name runs the "default" action.
  • The action's result, or the data passed to kit.Fail, becomes the page's form prop. Its type is generated from the actions and every kit.Fail call in the package.
  • Without JavaScript, the browser posts the form and Tern re-renders the page with form set.
  • With use:enhance, the form posts in the background. On success it resets the form and reruns the page's loads, then sets form. On failure it sets form. Redirects and errors behave as on a normal navigation.

To customize use:enhance, pass a function:

<form method="POST" use:enhance={() => {
	submitting = true;
	return async ({ update }) => {
		await update({ reset: false });
		submitting = false;
	};
}}>

#Validating forms

Put a form's rules on a Go struct, and kit.FormAction decodes and checks the post before your code runs:

type SignUp struct {
	Name     string `form:"name" validate:"required,max=80"`
	Email    string `form:"email" validate:"required,email"`
	Password string `form:"password" validate:"required,min=10"`
	Confirm  string `form:"confirm" validate:"same=password" label:"Confirm password"`
	Seats    int    `form:"seats" validate:"min=1,max=500"`
	Plan     string `form:"plan" validate:"oneof=free|team|enterprise"`
	Terms    bool   `form:"terms" validate:"required" message:"Accept the terms to continue."`
}

var Actions = kit.Actions{
	"default": kit.FormAction(func(ctx *kit.Ctx, in SignUp) (Welcome, error) {
		if users.Exists(ctx, in.Email) {
			return Welcome{}, kit.FieldError("email", "That email already has an account.")
		}
		return Welcome{Name: in.Name}, nil
	}),
}
Rule Means
required has a value; a checkbox is checked
min=N, max=N characters for text, the value for numbers, the day for dates (min=2026-01-01), choices for []string
len=N exactly N characters
email, url an email address; an http or https address
pattern=RE the whole value matches the regular expression; it must be the last rule
oneof=a|b|c one of these values
same=field the same as another field, such as a password confirmation
  • Names and messages: fields post under their form tag, else their json name. label names them in messages ("Seats must be at least 1."), and message replaces all of a field's messages.
  • Types: strings, numbers, bool (checkboxes), time.Time (a date, or with form:"when,datetime" a datetime-local value), []string (several values with one name), and pointers for optional fields.
  • Failing: if a rule fails, the action fails with 400 and a kit.Invalid: errors by field, the posted values to refill the form, and a message. Password fields, and fields that must match them, are never sent back.
  • Errors from your code: return kit.FieldError(field, message) or kit.FormError(message) from the action, or from a Validate() error method on the struct, to fail the same way.
  • In the browser: tern gen writes the same rules to ./$forms. Tern UI's Form checks them as the user types and before posting, with the same messages, and puts each error on its Field:
<script lang="ts">
	import { Button, Field, Form, Input, PasswordInput } from '@ternkit/tern/ui';
	import { forms } from './$forms';
	let { form } = $props();
</script>

<Form rules={forms.default} {form}>
	<Field label="Email" name="email"><Input type="email" /></Field>
	<Field label="Password" name="password"><PasswordInput autocomplete="new-password" /></Field>
	<Button type="submit">Sign up</Button>
</Form>

Without Tern UI, validate(forms.default, new FormData(form)) from @ternkit/tern returns the errors. The server always checks again, so the rules hold without JavaScript too. Typed endpoints check the same tags on their input and answer 400. There, a number that is 0 counts as not given, since a missing parameter leaves 0; use a pointer to accept an explicit 0.

#Endpoints

Export HTTP method handlers to serve JSON, files, feeds or webhooks:

// src/routes/api/posts/server.go
package posts

func GET(ctx *kit.Ctx) error {
	posts, err := db.Posts(ctx)
	if err != nil {
		return err
	}
	return ctx.JSON(200, posts)
}
  • ctx.JSON and ctx.Text write common responses; ctx.Writer() gives you the http.ResponseWriter for anything else.
  • A handler that writes nothing responds 204 No Content.
  • HEAD falls back to GET, and unsupported methods get 405 with an Allow header.
  • A directory can have both a page and endpoints. Browser requests (Accept: text/html) and form posts go to the page; other requests go to the endpoint.

#Typed endpoints

An endpoint can declare its input and output types instead of writing the response itself. tern gen then describes it in an OpenAPI document, and in a typed client for your components:

type ListQuery struct {
	Search string `json:"q,omitempty"`
	Status string `json:"status,omitempty"`
}

func GET(ctx *kit.Ctx, in ListQuery) ([]Project, error) { ... }

type NewProject struct {
	Name string `json:"name"`
}

func (p *NewProject) Validate() error { // optional; its error becomes a 400
	if p.Name == "" {
		return errors.New("name is required")
	}
	return nil
}

func POST(ctx *kit.Ctx, in NewProject) (Project, error) { ... }
  • Signatures: func(ctx *kit.Ctx) (Out, error) and func(ctx *kit.Ctx, in In) (Out, error) are typed. func(ctx *kit.Ctx) error stays a plain endpoint.
  • Where input comes from: query parameters for GET, HEAD and DELETE; a JSON body for other methods.
    • Query fields are named by their query tag, else their json name.
    • Supported query field types: strings, numbers, booleans, time.Time, pointers, and slices from repeated parameters.
    • Fields tagged path:"id" come from the route's parameters.
  • Failures: invalid input is answered with 400, a body that isn't JSON with 415, and errors work as in other endpoints.
  • Output: written as JSON.

Call them from components with $api. The endpoint, parameters, query, body and result are all type-checked:

<script lang="ts">
	import { ApiError } from '@ternkit/tern';
	import { api } from '$api';

	async function add(name: string) {
		try {
			const project = await api('POST /api/projects', { body: { name } });
		} catch (e) {
			if (e instanceof ApiError) alert(e.message); // the server's message; e.status has the code
		}
	}
</script>

#Streaming endpoints

An endpoint can send values as they come instead of one answer: the text of a model's reply as it's written, the progress of a long job, rows of a big export. It takes a *kit.Stream[T] and calls Send:

// src/routes/api/ask/server.go
type Ask struct {
	Question string `json:"question" validate:"required,max=2000"`
}

type Delta struct {
	Text string `json:"text"`
}

func POST(ctx *kit.Ctx, in Ask, out *kit.Stream[Delta]) error {
	for text, err := range model.Answer(ctx, in.Question) { // your model client's streaming loop
		if err != nil {
			return err
		}
		if err := out.Send(Delta{Text: text}); err != nil {
			return err // the reader went away; ctx is done too
		}
	}
	return nil
}

Read it in the browser with stream from $api, a typed async iterator:

<script lang="ts">
	import { stream } from '$api';

	let answer = $state('');

	async function ask(question: string) {
		answer = '';
		for await (const delta of stream('POST /api/ask', { body: { question } })) {
			answer += delta.text;
		}
	}
</script>
  • Signatures: func(ctx *kit.Ctx, in In, out *kit.Stream[T]) error, or without input, func(ctx *kit.Ctx, out *kit.Stream[T]) error. Input comes in and is checked as for other typed endpoints.
  • Partial results: T can be any type, so a stream can send a struct that fills in as the model writes it, such as a Draft with its Title and Points so far, and the page shows each version as it comes.
  • Errors: an error before the first Send is an ordinary error response. After it, the stream ends with the error, which stream throws as an ApiError, with the status and the message users may see. A stream that ends without finishing, such as when the connection drops, throws too.
  • Stopping: break out of the loop, or call abort() on the stream, to stop it: the request is cancelled, and the endpoint's next Send fails, so it stops working for nobody.
  • The wire: server-sent events, with each value's JSON as an event's data, and a done or error event at the end. Proxies such as Nginx don't buffer them (X-Accel-Buffering: no), the compression plugin leaves them as they are, and other clients, such as curl -N, read them too.

kit.WithOpenAPI("/api/openapi.json", "Acme API", "1.0.0") serves the OpenAPI 3.1 document. It has paths, parameters, request bodies and response schemas, with components for your Go structs, so you can generate clients for other languages or browse it with any OpenAPI tool.

#Hooks, middleware and configuration

main.go creates the app with the generated ternApp function and configures it with options:

func main() {
	app, err := ternApp(
		kit.WithHook(auth),
		kit.WithMiddleware(requestLogger),
		kit.WithLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil))),
	)
	if err != nil {
		log.Fatal(err)
	}
	log.Fatal(app.ListenAndServe())
}

// auth runs before every page, data, action and endpoint request.
func auth(ctx *kit.Ctx) error {
	if id := sessions.Get(ctx).GetString("user"); id != "" {
		ctx.Locals["user"] = id
	}
	return nil
}
Option Effect
kit.WithHook(fn) run fn(ctx) before every request; return an error such as kit.Redirect to stop it
kit.WithMiddleware(mw...) wrap the app in func(http.Handler) http.Handler middleware
kit.WithMatcher(name, fn) add a parameter matcher
kit.WithTrustedOrigins(...) allow cross-origin form posts from these origins
kit.WithoutCSRFProtection() disable cross-origin request protection
kit.WithBodyLimit(n) maximum request body size (default 1 MiB)
kit.WithTrustedProxies(cidrs...) the reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are believed; see ctx.ClientIP()
kit.WithLogger(l) the *slog.Logger for errors and server events
kit.WithAddr(addr) the listen address (default HOST:PORT from the environment, port 3000)
kit.WithCSPSources(directive, sources...) allow more sources in the default Content-Security-Policy, such as an analytics script
kit.WithContentSecurityPolicy(policy) replace the default Content-Security-Policy; Tern still adds the per-request script nonce
kit.WithoutContentSecurityPolicy(), kit.WithoutSecurityHeaders() turn off the default policy or headers (docs/security.md)
kit.WithoutSSR() render every page on the client only
kit.WithSSRWorkers(n), kit.WithSSRTimeout(d) tune server rendering

*kit.App is an http.Handler, so you can also mount it in your own server or router instead of calling ListenAndServe.

#Plugins

Plugins package reusable server behavior. tern add adds one to main.go, and gets its module when it isn't Tern's own:

go tool tern add compress health
go tool tern add github.com/you/tern-thing   # any package with a plugin
go tool tern add                             # the list

It puts the plugin in the app's kit.WithPlugins, with its import, and says what's left to set up, such as an environment variable. -n prints the change instead of making it. tern.land/plugins lists Tern's plugins and the community's; to list yours, add it to plugins/registry.json in a pull request.

Or add them in main.go yourself:

import (
	"github.com/Unfield/Tern/plugins/compress"
	"github.com/Unfield/Tern/plugins/health"
	"github.com/Unfield/Tern/plugins/requestlog"
	"github.com/Unfield/Tern/plugins/secure"
)

app, err := ternApp(kit.WithPlugins(
	health.New(health.Check("database", db.PingContext)),
	requestlog.New(requestlog.SkipAssets()),
	compress.New(),
	secure.New(secure.HSTS(365*24*time.Hour, true)),
))
Plugin What it does
plugins/health GET /healthz for liveness and GET /readyz for readiness, running your checks with a timeout. Readiness reports 503 during shutdown so load balancers drain first.
plugins/requestlog One structured slog line per request: method, path, status, duration, size and request ID.
plugins/signals Tells people from bots: TLS, HTTP/2 and header fingerprints, a score with reasons, an optional client check, device IDs. See docs/signals.md.
plugins/compress gzip for HTML, JSON, XML and text responses. Streaming still flushes as values resolve.
plugins/secure Changes the security headers Tern sends by default (no framing, a strict referrer policy, browser features off, cross-origin isolation), and turns on HSTS.
plugins/sessions Encrypted cookie sessions (AES-GCM): sessions.Get(ctx).Set("user", id), GetString, Value, flash messages, and secret rotation with OldSecrets.
plugins/metrics Prometheus metrics at /metrics: requests by route and status, a latency histogram, in-flight requests and Go runtime stats. Routes are labeled by pattern, so the number of series stays bounded.
plugins/ratelimit Replaces the default limit on writes (120 a minute per client) with its own: a token bucket per client, for any methods. Over the limit: 429 with Retry-After.
plugins/cors CORS for endpoints, including preflights; the allowed origins are also trusted by cross-site request protection.
plugins/oauth Sign in with GitHub, Google or any OpenID Connect provider. See Authentication.
plugins/oidc Make your app an OAuth 2.0 and OpenID Connect provider. See Authentication.

Plugins can also serve routes with a Ctx (Setup.Endpoint), start work with the server (Setup.OnStart), and exempt cookie-free endpoints from cross-site protection (Setup.AllowCrossOrigin).

#Writing a plugin

A plugin is any type with Name() string and Setup(*kit.Setup) error. Setup can register:

Method Adds
s.Hook(fn) a hook that runs before every request
s.Middleware(mw) net/http middleware
s.Matcher(name, fn) a parameter matcher
s.TrustOrigin(origin) an origin allowed to make cross-site writes
s.Handle(pattern, h) an http.Handler, served before routes ("GET /metrics")
s.Head(html), s.HeadFunc(fn) HTML in every page's <head>; HeadFunc gets the CSP nonce for inline scripts
s.OnShutdown(fn) work to do when the server stops
type Analytics struct{ SiteID string }

func (Analytics) Name() string { return "analytics" }

func (a Analytics) Setup(s *kit.Setup) error {
	s.HeadFunc(func(_ *kit.Ctx, nonce string) string {
		return fmt.Sprintf(`<script defer nonce="%s" src="https://stats.example.com/script.js" data-site="%s"></script>`, nonce, a.SiteID)
	})
	return nil
}

Plugins are set up in order after the other options, and each name may only be added once.

#Types

tern gen (run automatically by tern dev and tern build) generates a $types.d.ts for every route from your Go types:

import type { PageProps, LayoutProps, PageData, ActionData, RouteParams } from './$types';

Conversion follows encoding/json exactly:

Go TypeScript
string, bool, numbers string, boolean, number
time.Time, types with MarshalText string
[]byte string (base64)
[]T, map[string]T, *T Array<T> | null, Record<string, T> | null, T | null (nil encodes as null)
[N]T Array<T>
named structs interface declarations
omitempty, omitzero optional fields
json:",string" string
kit.Deferred[T] Promise<T>
interfaces, types with MarshalJSON unknown

tern gen also checks the signatures of your exports and reports mistakes at the right line, before the Go compiler sees the generated code.

#Rendering

Pages render on the server and hydrate in the browser. Users see content before JavaScript loads, search engines see real HTML, and forms work without JavaScript.

Server rendering runs your Svelte components in an embedded JavaScript engine inside the Go binary (QuickJS, pure Go, no cgo). Browser APIs like window and document are not available while rendering; use them in onMount or $effect, which only run in the browser. This matches the rules for SvelteKit.

Besides the JavaScript language, code that runs during rendering can use:

  • URL and URLSearchParams
  • TextEncoder and TextDecoder (UTF-8), atob and btoa
  • crypto.getRandomValues and crypto.randomUUID, backed by Go's crypto/rand
  • structuredClone
  • Event, CustomEvent, EventTarget, AbortController and AbortSignal
  • performance.now() and queueMicrotask
  • console, which logs through the app's logger, and process.env.NODE_ENV
  • an Intl that formats like en-US; the browser re-renders with the user's real locale

Timers (setTimeout, setInterval) can be called but never fire, as in any server renderer: the HTML is sent when the render returns. fetch rejects with an explanation; load data in Go instead.

The server bundle resolves libraries without the node export condition, so they use their portable builds rather than ones that need Node's Buffer or node:crypto. A few packages only have a portable build under the browser condition; Tern uses it for nanoid, and tern({ ssrBrowserBuilds: ['some-package'] }) in vite.config.js adds others.

Every push renders these libraries in Tern and checks the HTML is identical to Node's: bits-ui, @lucide/svelte, svelte-sonner, mode-watcher, paneforge, runed, @tanstack/svelte-query, date-fns, dayjs, marked, highlight.js, KaTeX, nanoid, uuid, zod, valibot, d3, floating-ui, clsx, tailwind-merge, lodash, svelte-i18n and layerchart (see tests/ssr-compat).

Rendered HTML is cached by the page's data and URL (32 MiB by default, kit.WithSSRCache), so a page that looks the same for many visitors renders once. Keep what a component renders on the server a function of its data: output that depends on the clock or Math.random() does not hydrate cleanly, and it could be served from the cache.

If a server render fails or exceeds 5 seconds, Tern logs the error and sends the page for client rendering instead, so a rendering bug does not take the page down.

To render a page only on the client, add const SSR = false to its route package. kit.WithoutSSR() does this for the whole app.

#Streaming

Wrap slow data in kit.Defer so the page does not wait for it:

type Data struct {
	Post     Post                     `json:"post"`
	Comments kit.Deferred[[]Comment] `json:"comments"`
}

func Load(ctx *kit.Ctx) (Data, error) {
	post, err := db.Post(ctx, ctx.Param("slug"))
	if err != nil {
		return Data{}, err
	}
	return Data{
		Post:     post,
		Comments: kit.Defer(ctx, func() ([]Comment, error) { return db.Comments(ctx, post.ID) }),
	}, nil
}
{#await data.comments}
	<p>Loading comments…</p>
{:then comments}
	{#each comments ?? [] as c}<Comment {c} />{/each}
{:catch error}
	<p>Could not load comments: {error.message}</p>
{/await}

The function starts immediately. The page renders and is sent with the pending state, and the value streams to the browser when it resolves. Errors reject the promise; unexpected error messages are hidden as usual.

#Prerendering

Pages whose content is the same for everyone can be rendered once at build time. For pages that change without a build, render them on demand instead: see docs/caching.md.

package about

const Prerender = true

For dynamic routes, list the parameters:

package post

const Prerender = true

func Entries() ([]map[string]string, error) {
	slugs, err := db.AllSlugs(context.Background())
	if err != nil {
		return nil, err
	}
	entries := make([]map[string]string, len(slugs))
	for i, s := range slugs {
		entries[i] = map[string]string{"slug": s}
	}
	return entries, nil
}

Endpoints can be prerendered too, for files such as sitemap.xml or feed.xml: add const Prerender = true to a route package with a GET handler. The URL needs a file extension, so the static file gets the right type.

tern build saves each page's HTML and data into the binary and serves them as static files with ETags. Prerendered pages still hydrate and navigate on the client, and their form actions still run on the server. Hooks do not run for prerendered pages, so only prerender pages that are public.

tern gen writes a $routes module with every route's URL pattern. Build links with route() instead of string templates:

<script lang="ts">
	import { route } from '$routes';
</script>

<a href={route('/projects/[id]', { id: project.id })}>{project.name}</a>

TypeScript checks that the pattern exists and that required parameters are given, so renaming a route directory turns every stale link into a compile error. Optional parameters ([[lang]]) may be left out, and rest parameters ([...path]) accept a string or an array of segments. Parameter values are URL-encoded.

#The client API

Import from @ternkit/tern in your components:

Export Purpose
page reactive state for the current page: url, params, route.id, status, error, data, form, state
env the server's PUBLIC_ environment variables, such as env.PUBLIC_API_URL; other variables never reach the browser
navigating.current the navigation in progress, or null
goto(href, opts) navigate; options: replaceState, noScroll, keepFocus, invalidateAll, state
invalidate(key) rerun the loads that called ctx.Depends(key), and the page's load
invalidateAll() rerun every load for the current page
beforeNavigate(fn) run before each navigation; nav.cancel() stops it (for example with unsaved changes)
onNavigate(fn) run before the new page renders; return a promise to delay it (view transitions) or a function to run afterwards
afterNavigate(fn) run after each navigation, including the first page load
preloadData(href) load a page's data and code ahead of time
enhance progressive form enhancement (use:enhance)
applyAction(result) apply an action result as use:enhance does

Links to pages in the app navigate on the client. These attributes change link behavior, on the link or any ancestor:

Attribute Effect
data-tern-preload="hover" preload when the pointer rests on a link (the starter template sets this on <body>)
data-tern-preload="tap" preload on mousedown or touch (the default)
data-tern-preload="off" never preload
data-tern-reload always do a full page load
data-tern-noscroll keep the scroll position
data-tern-replacestate replace the history entry instead of adding one

Links with target, download, rel="external", or another origin are left to the browser. Forms with method="GET", such as search boxes, also navigate on the client; when they target the current page, focus and scroll position are kept so typing is not interrupted. After each navigation, Tern restores scroll positions on back and forward, moves focus to the document, and announces the new page title to screen readers.

#Reloading data

On a client-side navigation, Tern reruns a layout's load only when something it used changed:

Used in the load Reruns when
ctx.Param("id") that parameter changes
ctx.URL() the URL (path or query) changes
kit.Parent[T](ctx) a parent layout reruns
ctx.Depends("app:projects") the browser calls invalidate('app:projects')

Other layouts are reused from the browser's copy, so moving between pages under a layout does not repeat its queries. The page's own load always runs. Full page loads, form actions and invalidateAll() run every load.

Reading ctx.Request directly is not tracked. If a layout depends on the request in a way Tern cannot see (for example a header), call ctx.URL() or ctx.Depends so it reruns when needed, or use invalidateAll() after the change.

func LayoutLoad(ctx *kit.Ctx) (Shell, error) {
	ctx.Depends("app:projects")
	return loadShell(ctx)
}
<button onclick={() => invalidate('app:projects')}>Refresh</button>

#Content Security Policy

Every page is sent a Content-Security-Policy (ADR 0027):

default-src 'self'; script-src 'self' 'unsafe-hashes' 'sha256-7dQw…' 'nonce-…';
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:;
connect-src 'self'; object-src 'none'; base-uri 'self'

Only the app's own scripts run, and inline scripts with the request's nonce. Tern puts the nonce on its own scripts and on inline scripts in <svelte:head>, such as a dark-mode script; use %tern.nonce% in src/app.html for your own. Inline scripts in a page's body don't get it, since that's where {@html} puts content that may come from users.

To load something from elsewhere, name its origin:

kit.WithCSPSources("script-src", "https://plausible.io"),
kit.WithCSPSources("connect-src", "https://plausible.io"),
kit.WithCSPSources("frame-src", "https://www.youtube-nocookie.com"),

Under tern dev, the overlay shows what the policy blocked, with the line to add. kit.WithContentSecurityPolicy(policy) replaces the policy (Tern still adds the nonce), kit.WithContentSecurityPolicyReportOnly tries one without enforcing it, and kit.WithoutContentSecurityPolicy() sends none. Prerendered pages are static files and get no policy.

#Static files and assets

  • Files in static/ are served at the root: static/robots.txt is /robots.txt.
  • Assets you import are processed by Vite and get content-hashed names: import logo from '$lib/logo.svg'.
  • Hashed files are cached by browsers for a year; everything else revalidates with ETags. Text assets are precompressed with gzip.

#Authentication

Tern's building blocks for sign-in work together: accounts keeps user accounts, plugins/sessions remembers who is signed in, plugins/oauth signs users in with other providers, plugins/oidc lets other apps sign in with yours, and rbac checks permissions.

#User accounts

The accounts package is user accounts, done: sign-up and sign-in with a password or a passkey, email verification, password resets, and the devices a user is signed in on, each of which they can sign out. It keeps users and sessions in your database, SQLite or PostgreSQL, and Tern UI has a screen for each step.

acc, err := accounts.New(db, accounts.Options{Send: accounts.SendWith(mailer, queue)})
if err := acc.Migrate(ctx); err != nil { ... }
app, err := ternApp(kit.WithPlugins(acc))

In loads and actions, accounts.Current(ctx) is the signed-in user, acc.Require(ctx) redirects signed-out users to /login?next=…, and acc.SignIn, acc.SignUp and the rest change who's signed in. accounts.FormError turns a wrong password or a taken email into a form error in the user's language:

var Actions = kit.Actions{
	"default": kit.FormAction(func(ctx *kit.Ctx, in Credentials) (struct{}, error) {
		if _, err := acc.SignIn(ctx, in.Email, in.Password, in.Remember); err != nil {
			return struct{}{}, accounts.FormError(ctx, err)
		}
		return struct{}{}, kit.Redirect(http.StatusSeeOther, "/account")
	}),
}

See docs/accounts.md for every page an app needs, and examples/accounts for a working app. The rest of this section builds sign-in from smaller pieces, for apps that keep users elsewhere.

#Sign in with a password

The pieces: a form action signs the user in, a hook reads the session on every request, and a layout guards the pages that need it. examples/todos is a working app built this way.

1. Sessions. Add the plugin in main.go, with a secret of at least 32 characters that stays the same across restarts:

app, err := ternApp(
	kit.WithPlugins(
		sessions.New(os.Getenv("SESSION_SECRET")),
		ratelimit.New(ratelimit.Rate(10, time.Minute)), // slows down password guessing
	),
	kit.WithHook(loadUser),
)

The session is an encrypted, HttpOnly cookie, so the browser's scripts can't read it and users can't change it.

2. Signing in, in src/routes/login/page.server.go:

type LoginFailed struct {
	Email string `json:"email"`
	Error string `json:"error"`
}

var Actions = kit.Actions{
	"default": kit.Action(func(ctx *kit.Ctx) (struct{}, error) {
		form, err := ctx.Form()
		if err != nil {
			return struct{}{}, err
		}
		email := form.Get("email")
		user, err := users.CheckPassword(ctx, email, form.Get("password"))
		if err != nil {
			// The same message whether the email or the password was wrong.
			return struct{}{}, kit.Fail(400, LoginFailed{Email: email, Error: "Wrong email or password."})
		}
		if err := sessions.Get(ctx).Set("user", user.ID); err != nil {
			return struct{}{}, err
		}
		return struct{}{}, kit.Redirect(http.StatusSeeOther, "/app")
	}),
}
<form method="POST" use:enhance>
	<input name="email" type="email" autocomplete="username" value={form?.email ?? ''} required />
	<input name="password" type="password" autocomplete="current-password" required />
	<button>Sign in</button>
	{#if form?.error}<p role="alert">{form.error}</p>{/if}
</form>

Store passwords only as hashes from a slow password hash, such as golang.org/x/crypto/bcrypt or argon2id.

3. Knowing who is signed in. A hook runs before every page, data request, action and endpoint, and puts the user in ctx.Locals:

func loadUser(ctx *kit.Ctx) error {
	id := sessions.Get(ctx).GetString("user")
	if id == "" {
		return nil
	}
	user, err := users.Find(ctx, id)
	if err != nil {
		return nil //nolint:nilerr // a deleted user is signed out
	}
	ctx.Locals["user"] = user
	return nil
}

Plugins' hooks run before yours, so the session is ready. Live components see the same Locals through c.Locals().

4. Guarding pages. Put the pages that need a user in a group directory, such as src/routes/app.group/. A group shares a layout without adding to the URL. Its layout load runs for every page inside, including data requests during client-side navigation:

// src/routes/app.group/layout.server.go
func LayoutLoad(ctx *kit.Ctx) (Account, error) {
	user, ok := ctx.Locals["user"].(*users.User)
	if !ok {
		return Account{}, kit.Redirect(http.StatusSeeOther, "/login")
	}
	return Account{Name: user.Name}, nil
}
  • Actions and endpoints: a layout load guards pages, but actions and endpoints don't run it. Check the user in each one too, or guard a whole path with a hook, as policy.Hook in Roles and permissions does.

5. Signing out, as an action, since signing out changes state and must not be a link:

"logout": kit.Action(func(ctx *kit.Ctx) (struct{}, error) {
	sessions.Get(ctx).Clear()
	return struct{}{}, kit.Redirect(http.StatusSeeOther, "/")
}),
<form method="POST" action="/app?/logout" use:enhance><button>Sign out</button></form>
  • Why use:enhance reruns every load: after an action, use:enhance follows the redirect and reruns the loads, so layouts that showed the user see the cleared session. No page is left showing stale, signed-in data.
  • Cookie sessions can't be revoked on the server: a copied cookie stays valid until it expires. To sign a user out everywhere, store a session version on the user and compare it in the hook.

#Sign in with GitHub, Google or OpenID Connect

oauth.New(oauth.Options{
	Secret: secret, // at least 32 bytes; the sessions secret works
	Providers: []*oauth.Provider{
		oauth.GitHub(os.Getenv("GITHUB_CLIENT_ID"), os.Getenv("GITHUB_CLIENT_SECRET")),
		oauth.Google(os.Getenv("GOOGLE_CLIENT_ID"), os.Getenv("GOOGLE_CLIENT_SECRET")),
		oauth.OIDC("company", "Company SSO", "https://sso.example.com", clientID, clientSecret),
	},
	OnLogin: func(ctx *kit.Ctx, id oauth.Identity) error {
		user, err := users.FindOrCreate(ctx, id.Provider, id.Subject, id.Email)
		if err != nil {
			return err
		}
		return sessions.Get(ctx).Set("user", user.ID)
	},
})

A link to /auth/github/login?next=/dashboard starts the sign-in. Register https://your.app/auth/github/callback as the redirect URL with the provider. After OnLogin, the user lands on next. next must be a path on your site, which prevents open redirects. If the user cancels, or the sign-in fails, they go to /login?error=<code> (ErrorURL).

  • Flow security: every sign-in uses PKCE and a state value, plus a nonce for OpenID Connect. These travel in a short-lived encrypted cookie.
  • Token checks: ID tokens are verified against the provider's published keys.
  • Identifying users: look users up by Provider and Subject. Use the email only if EmailVerified is true.
  • Identity fields: Identity also carries the provider's claims and its OAuth token, for calling its API.
  • Proxies: behind a proxy that changes the host, set URL to the app's public address.
  • With the accounts package: set Accounts: acc instead of OnLogin, and sign-ins become accounts users. See docs/accounts.md.

#Be an OpenID Connect provider

With plugins/oidc, your app is the place users sign in. Other apps (your other services, a wiki, a mobile app, a single-page app) send users to it and get tokens back:

store, _ := oidc.NewSQLStore(db)
provider := oidc.New(oidc.Options{
	Issuer: "https://auth.example.com",
	Store:  store,
	Clients: []oidc.Client{
		{ID: "wiki", Secret: os.Getenv("WIKI_SECRET"), Name: "Team wiki",
			RedirectURIs: []string{"https://wiki.example.com/oauth/callback"}},
		{ID: "mobile", Public: true, Trusted: true, RedirectURIs: []string{"com.example.app:/callback"}},
		{ID: "billing", Secret: os.Getenv("BILLING_SECRET"), GrantTypes: []string{"client_credentials"}, Scopes: []string{"api:read"}},
	},
	Scopes:   []string{"api:read", "api:write"},
	LoginURL: "/login", // signed-out users go here with ?next=
	Authenticate: func(ctx *kit.Ctx) (string, error) {
		return sessions.Get(ctx).GetString("user"), nil
	},
	Claims: func(ctx context.Context, subject string, scopes []string) (map[string]any, error) {
		u, err := users.Get(ctx, subject)
		return map[string]any{"email": u.Email, "email_verified": true, "name": u.Name}, err
	},
})

Endpoints:

Endpoint What it does
/.well-known/openid-configuration Discovery
/oauth/jwks Signing keys
/oauth/authorize Sign-in and consent
/oauth/token Issues tokens
/oauth/userinfo The user's claims
/oauth/revoke Revocation (RFC 7009)
/oauth/introspect Introspection (RFC 7662)

Flows:

  • Authorization code with PKCE: PKCE is required for public clients, and only S256 is accepted. The authorization response includes iss (RFC 9207).
  • Refresh tokens: they rotate on every use. A refresh token used twice revokes every token from that sign-in, since the reuse means one was stolen.
  • Client credentials: for services that act as themselves rather than for a user.

Tokens: access tokens are RS256 JWTs (typ: at+jwt, RFC 9068), and ID tokens are RS256 JWTs.

Consent: apps that aren't Trusted get a consent screen, which can't be framed. The answer is remembered for each user and app.

Validation: before anything is redirected, the client and its exact redirect URI are checked, so the endpoint can't send users to arbitrary sites. Codes and refresh tokens are stored only as hashes.

Your own APIs: protect them with the provider's tokens:

func GET(ctx *kit.Ctx) error {
	token, err := provider.Bearer(ctx, "api:read") // 401 or 403 with WWW-Authenticate
	if err != nil {
		return err
	}
	return ctx.JSON(200, projectsOf(token.Subject))
}

Other services can verify the same tokens against /oauth/jwks, or ask /oauth/introspect. The signing key is created on first start and kept in the store. MemoryStore forgets everything on restart, so use NewSQLStore in production.

  • Clients that register themselves: with ClientMetadataDocuments: true, a client may use an HTTPS URL as its client_id, and the provider fetches the client's name and redirect addresses from it (Client ID Metadata Documents). That's how AI assistants connect to an app; see Apps for assistants. Such clients are public, use PKCE and always get the consent screen.
  • Tokens for a resource: a client may name the API it wants a token for with resource (RFC 8707), and the token's audience is then that URL. token.For("https://example.com/api") checks it. Bearer checks scopes, not the audience, so an API that must accept only its own tokens checks For too. Resources on the issuer's origin are allowed, and Options.Resources adds others.
  • Your own consent page: set ConsentURL to a page of your app, and the plugin sends users there instead of its built-in page. The page's load returns provider.ConsentRequest(ctx), and Tern UI's ConsentScreen shows it and posts the decision back. The plugin checks the request again on that post.

#Roles and permissions

var policy = rbac.New(map[string][]string{
	"viewer": {"projects:read"},
	"editor": {"role:viewer", "projects:write"},
	"admin":  {"*"},
}, func(ctx *kit.Ctx) []string {
	return currentUser(ctx).Roles // nil when signed out
})

func Load(ctx *kit.Ctx) (Page, error) {
	if err := policy.Require(ctx, "projects:read"); err != nil {
		return Page{}, err // 401 signed out, 403 without the permission
	}
	...
}
  • Wildcards: "projects:*" grants every projects: permission.
  • Role includes: "role:viewer" includes another role's permissions.
  • Checks in components: policy.Can(ctx, perm) returns a bool, for showing or hiding parts of a page.
  • Guarding routes: with policy.Guard() among the app's options, var Access = kit.Permission("admin:access") in a route's package guards it and every route below (who may use a route). kit.WithHook(policy.Hook("/admin", "admin:access")) guards everything under a path, routes or not.

#Databases

Tern generates typed Go functions from SQL queries in queries/*.sql, checked against the schema your migrations build (docs/sql.md). It doesn't require them: database/sql, sqlc, GORM or anything else works in loads and actions too. Its own database packages work with SQLite and PostgreSQL through database/sql, with any driver (such as modernc.org/sqlite or github.com/jackc/pgx/v5/stdlib), and detect which one you use.

#Database migrations

tern migrate new add users creates migrations/<timestamp>_add_users.sql. Write the change, and optionally an -- +down section that undoes it:

CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);

-- +down
DROP TABLE users;

Embed the directory and apply pending migrations when the server starts:

//go:embed migrations/*.sql
var migrations embed.FS

app, err := ternApp(kit.WithPlugins(migrate.Plugin(db, migrations)))

Each migration runs in a transaction and is recorded in tern_migrations. If a migration fails, the server doesn't start. Editing a migration after it was applied is reported as an error (migrate.ErrChanged); add a new one instead. On PostgreSQL, instances starting at the same time take turns. migrate.Up, migrate.Down and migrate.Status are available for your own tooling.

go tool tern migrate check, and tern doctor before a deploy, look for changes that fail or break the app while a deploy rolls out, when the old version still runs beside the new:

  • Errors: a NOT NULL column added without a DEFAULT to a table that already exists, which fails once it has rows. A SQLite app's migrations also run up, down and up again in a scratch database, which finds migrations that don't run and -- +down sections that don't undo them.
  • Warnings: dropping or renaming a table or column the running version may still use; UPDATE or DELETE without WHERE, and TRUNCATE; on PostgreSQL, changing a column's type, which locks the table. Do these in two releases, the code first, or mark the statement on the line before with -- safe: <why> once you know it is.
  • Notes: a migration without -- +down, which migrate.Down stops at.

migrate.Check runs the same checks from your own tools.

#Background jobs

The jobs package runs work outside requests, such as sending email, with retries. Jobs are stored in your database, so they survive restarts, and instances sharing a database run each job once:

q, err := jobs.New(db, jobs.Options{})

sendWelcome := jobs.Handle(q, "email.welcome", func(ctx context.Context, u User) error {
	return mailer.Send(ctx, u.Email, "Welcome!")
}, jobs.MaxAttempts(5), jobs.Timeout(time.Minute))

app, err := ternApp(kit.WithPlugins(q))
// In an action, after creating the user:
if _, err := sendWelcome.Enqueue(ctx, user); err != nil {
	return err
}
  • The payload is stored as JSON and decoded into the handler's type.
  • A handler that returns an error or panics is retried with exponential backoff (about 1s, 4s, 16s, and so on, up to an hour) until it has run MaxAttempts times.
  • jobs.Delay(d) and jobs.At(t) run a job later. jobs.Unique("reminder:42") skips enqueueing while a job with the same key is queued or running. jobs.InTx(tx) enqueues inside your transaction, so the job only exists if it commits.
  • The workers start with the server, after migrations, and stop when it shuts down. Running jobs get the shutdown timeout to finish.
  • q.Stats, q.List and q.Retry let you build an admin page.

#Scheduled tasks

q.Schedule("cleanup", "0 3 * * *", func(ctx context.Context) error {
	return deleteExpiredSessions(ctx, db)
})
q.Schedule("ping", "@every 30s", checkMonitors)

Schedules take five-field cron expressions (*/5 * * * *, 0 9 * * mon-fri), @hourly, @daily, @weekly, @monthly and @every <duration>. They run in UTC unless Options.Location says otherwise. Each run is a job, retried like any other. With several instances, each run happens once, and a run doesn't start while the previous one is still going.

#Email

Write emails as Svelte components in src/emails/. They render on the server with the same engine as pages. Their styles are included in the HTML, since mail clients can't load your stylesheets. The <title> becomes the subject:

<!-- src/emails/Welcome.svelte -->
<script module lang="ts">
	export const preview = { name: 'Ada', link: 'https://example.com/confirm?t=demo' };
</script>

<script lang="ts">
	let { name, link }: { name: string; link: string } = $props();
</script>

<svelte:head><title>Welcome, {name}</title></svelte:head>

<h1>Welcome, {name}!</h1>
<p><a href={link}>Confirm your email address</a> to get started.</p>

<style>
	h1 { font-family: system-ui, sans-serif; }
</style>

Render and send it from Go with the mail package:

mailer := mail.New(mail.SMTP{Host: "smtp.example.com", Port: 587, Username: user, Password: pass}, "Acme <hello@acme.com>")

msg, err := mailer.Template(ctx, app, "Welcome", map[string]any{"name": u.Name, "link": link})
msg.To = []string{u.Email}
err = mailer.Send(ctx, msg)

The message: it carries the HTML and a plain-text version made from it (links appear as text (url)). app.RenderEmail returns these parts without sending.

SMTP:

  • Port 465 uses TLS from the start. Other ports require STARTTLS unless Insecure is set, for local servers such as Mailpit.
  • Addresses are validated, Bcc recipients never appear in the headers, and line breaks can't inject headers.

Development:

  • Use &mail.Log{} as the transport to print messages instead of sending them.
  • /__tern/emails lists every template, and /__tern/emails/<Name> previews one with its preview props. Add ?text for the text version.
  • Previews update as you edit the templates.

Retries: send in the background with mail.Job:

sendMail := mail.Job(q, mailer) // a jobs kind, retried up to five times
_, err := sendMail.Enqueue(ctx, *msg)

#File uploads and storage

The storage package keeps files in a Bucket: a directory on disk (storage.Disk("data/files")) or an S3-compatible bucket, such as AWS S3, Cloudflare R2, MinIO or Backblaze B2:

files, err := storage.NewS3(storage.S3Options{
	Endpoint: "https://s3.eu-central-1.amazonaws.com", Region: "eu-central-1", Bucket: "acme",
	AccessKey: os.Getenv("S3_ACCESS_KEY"), SecretKey: os.Getenv("S3_SECRET_KEY"),
})

Receive files in a form action with storage.Receive. It raises the request's body limit to fit, checks sizes, and detects each file's type from its contents rather than trusting the browser. Save stores JPEG and PNG photos without their metadata, such as the GPS coordinates of where they were taken; the pixels stay as they are, and ReceiveOptions.KeepMetadata keeps it:

"default": kit.Action(func(ctx *kit.Ctx) (struct{}, error) {
	uploads, err := storage.Receive(ctx, "avatar", storage.ReceiveOptions{MaxSize: 5 << 20, Types: []string{"image/png", "image/jpeg", "image/webp"}})
	var invalid *storage.InvalidUpload
	if errors.As(err, &invalid) {
		return struct{}{}, kit.Fail(400, Invalid{Error: invalid.Message})
	}
	if err != nil {
		return struct{}{}, err
	}
	key := storage.NewKey("avatars", uploads[0].Filename) // avatars/2026/11/3f9a…-portrait.png
	return struct{}{}, uploads[0].Save(ctx, files, key)
}),
<form method="POST" enctype="multipart/form-data" use:enhance>
	<input type="file" name="avatar" accept="image/*" />
	<button>Upload</button>
</form>

Serve the files with the storage.Serve plugin:

signer := storage.NewSigner(secret)
kit.WithPlugins(storage.Serve("/files/", files, storage.ServeOptions{Signer: signer}))

url := signer.URL("/files/", key, time.Hour) // a link that expires
  • Signed links: with a Signer, every file is private. Only signed, unexpired links work. Serve needs a Signer, or Public: true for files anyone may see, such as a site's images: files are private unless you say otherwise.
  • Unsafe file types: files are sent with nosniff and a sandboxing Content Security Policy. Only images, media, PDF and plain text display inline; HTML, SVG and everything else download. An uploaded file can't run scripts on your site.
  • Range requests: disk files support them, for video.
  • S3: files are redirected to short-lived presigned URLs, so downloads don't pass through your app.
  • Direct uploads: bucket.PresignPut(key, ttl) lets browsers upload large files straight to S3.

S3 requests are signed with AWS Signature Version 4. The signing is tested against AWS's published examples, and in CI against an S3-compatible server (SeaweedFS).

#Settings and secrets

The settings package reads the app's settings from the environment into a struct, and reports every missing or invalid one at once when the app starts, before it serves anything:

type Settings struct {
	DatabaseURL   string          `env:"DATABASE_URL,required"`
	SessionSecret settings.Secret `env:"SESSION_SECRET,required,min=32"`
	Workers       int             `env:"WORKERS" default:"4"`
	Timeout       time.Duration   `env:"TIMEOUT" default:"10s"`
}

var s Settings
if err := settings.Load(&s); err != nil {
	log.Fatal(err) // settings: DATABASE_URL is required and isn't set; SESSION_SECRET must be at least 32 characters, and is 12
}

A settings.Secret never shows its value: printed, logged or encoded as JSON it's [secret], so a log line or an error report can't leak it. s.SessionSecret.Reveal() returns it, where it's used. tern doctor compares a deploy target's environment with .env.example.

#Encrypted fields

The sealed package encrypts personal data in database columns with AES-256-GCM. A sealed.String is an ordinary string in Go and ciphertext in the database, so a copy of the database or a backup shows nothing:

sealed.SetKeys(s.FieldKey) // at start: a settings.Secret of 32 characters or more

db.ExecContext(ctx, `UPDATE users SET phone = ? WHERE id = ?`, sealed.String(phone), id)

var phone sealed.String
db.QueryRowContext(ctx, `SELECT phone FROM users WHERE id = ?`, id).Scan(&phone)
  • Rotating keys: sealed.SetKeys(newKey, oldKey) seals with the new key and still opens what the old one sealed; sealed.Current(stored) finds rows to write again before dropping it.
  • Finding rows: encrypted values can't be searched. Store sealed.Blind(value), a keyed hash, beside one and look rows up by it: WHERE phone_hash = ?.
  • Existing data: sealed.AllowPlain(true) reads values that aren't encrypted yet as they are, and they're sealed when written again.
  • Logs: sealed values log as [sealed]. sealed.Bytes is the same for binary data.
  • Typed SQL: -- param phone sealed.String and -- column phone sealed.String in queries.

#Requests to addresses users give

Fetching a URL a user typed (a webhook, a link preview, an avatar) can reach the app's own network: the cloud's metadata service, the database, an admin panel on localhost. safehttp.New(safehttp.Options{}) is an *http.Client that only connects to public addresses, checked on the address it actually dials, so DNS names and redirects that point inward are refused with safehttp.ErrBlocked. It ignores proxy settings, follows at most 5 redirects, only uses ports 80 and 443, and limits time and the size of responses. safehttp.Get(ctx, url) uses a default one; Options.Allow names private addresses you trust. The images plugin fetches originals with it.

#Realtime

#Server-sent events

ctx.EventStream keeps a response open and pushes events to the browser. It sends a keep-alive comment every 20 seconds and ends when the client disconnects or the server shuts down:

func GET(ctx *kit.Ctx) error {
	return ctx.EventStream(func(ctx context.Context, s *kit.EventStream) error {
		for {
			select {
			case <-ctx.Done():
				return nil
			case <-time.After(time.Second):
				if err := s.Send("tick", map[string]any{"at": time.Now()}); err != nil {
					return err
				}
			}
		}
	})
}

Send encodes data as JSON, or sends strings as they are. SendEvent also sets an ID, which the browser sends back as LastEventID() when it reconnects, and a Retry delay.

In components, stream from @ternkit/tern subscribes and keeps the latest value in reactive state:

<script lang="ts">
	import { stream } from '@ternkit/tern';
	let { data } = $props();
	const clock = stream<{ at: string }>('/api/clock', { events: ['tick'], initial: data.clock });
</script>

<p>{clock.current?.at} ({clock.status})</p>

It doesn't connect during server rendering, where current is the initial value, and it closes when the component is destroyed. The browser reconnects by itself after errors.

#WebSockets

ctx.WebSocket upgrades an endpoint request. Receive and Send read and write JSON; ReceiveText, SendText and Conn give lower-level access:

func GET(ctx *kit.Ctx) error {
	return ctx.WebSocket(nil, func(ctx context.Context, ws *kit.WebSocket) error {
		for {
			var msg ChatMessage
			if err := ws.Receive(&msg); err != nil {
				return err // a closed connection ends the handler without an error being logged
			}
			hub.Publish("chat", msg)
		}
	})
}

Only pages on the app's own origin can connect; list other origins in WebSocketOptions.Origins. When the server shuts down, connections are closed with "going away". In components, socket connects, queues messages sent while connecting, decodes JSON and reconnects with backoff:

<script lang="ts">
	import { socket } from '@ternkit/tern';
	const chat = socket<ChatMessage, ChatMessage>('/api/chat', { onMessage: (m) => messages.push(m) });
</script>

#Publish and subscribe

The realtime package connects the parts of an app that change things with the browsers watching them:

var hub = realtime.NewHub[MonitorStatus]()

// When a check finishes:
hub.Publish("monitors", status)

// An endpoint that streams the topic to the browser:
func GET(ctx *kit.Ctx) error { return realtime.Stream(ctx, hub, "monitors") }

hub.Subscribe(ctx, topic) returns a channel for your own WebSocket or stream handlers. Subscribers that fall more than Buffer messages behind miss messages instead of slowing publishers down. The hub is in memory; to run several instances, implement realtime.Broker with Redis, NATS or Postgres LISTEN/NOTIFY and use realtime.NewHubWith.

Under tern dev, your WebSockets are served by the Go app; only Vite's own connection goes to Vite.

#Live components

A component's logic can be Go running on the server, with Svelte-style runes, while its markup stays Svelte:

<script lang="go" live>
import "github.com/Unfield/Tern/goscript"

func Setup(c *goscript.Component) {
	count := goscript.State(c, "count", 0)
	c.Func("increment", func() { count.Update(func(n int) int { return n + 1 }) })
}
</script>

<button onclick={increment} disabled={increment.pending}>{count}</button>

The page talks to the server over one WebSocket. State is rendered on the server for the first load, changes reach the page as patches, and components can share state across visitors and server processes. See Live components for the API, access control, bindings, testing and deployment.

#Markdown and content

#Markdown pages

A +page.md file is a page written in Markdown. tern gen renders it to HTML, and it is wrapped in the directory's layouts like any page. Front matter title and description become the page's <title> and meta description:

---
title: Privacy
description: What we store about you.
---
# Privacy

We store **nothing** you didn't type.

A directory has either a +page.md or a +page.svelte. The component's module exports metadata (the front matter) and headings.

#Content collections

For blogs, docs and changelogs, put Markdown files in src/content/<collection>/ and read them in Go with the content package. Front matter is YAML, decoded into your struct:

package blog

type Post struct {
	Title string    `yaml:"title" json:"title"`
	Date  time.Time `yaml:"date" json:"date"`
}

var Posts = &content.Collection[Post]{
	Dir:  "blog",
	Sort: content.Newest(func(p Post) time.Time { return p.Date }),
}

func Load(ctx *kit.Ctx) (List, error) {
	posts, err := Posts.Summaries(ctx) // without bodies, for list pages
	return List{Posts: posts}, err
}
package post

const Prerender = true

func Entries(ctx *kit.Ctx) ([]map[string]string, error) { /* Posts.Slugs(ctx) */ }

func Load(ctx *kit.Ctx) (content.Entry[blog.Post], error) {
	return blog.Posts.Get(ctx, ctx.Param("slug")) // 404 if there is no such post
}
<h1>{data.data.title}</h1>
<article>{@html data.html}</article>

An Entry has the Slug (the file path without .md, with guides/index.md as guides), the decoded Data, the rendered HTML, the Headings for a table of contents, an Excerpt and ReadingMinutes.

  • src/content is embedded in the binary and read once. Under tern dev it is read from disk on every request, so edits show on reload.
  • Files with draft: true in their front matter are left out in production but shown under tern dev. Files starting with _ are skipped.

Markdown is rendered with goldmark: GitHub-flavored Markdown (tables, task lists, autolinks, strikethrough), footnotes, smart quotes, heading IDs and highlighted code blocks. Highlighting uses CSS classes; content.CodeCSS("github") returns a stylesheet for them in any chroma style. Raw HTML in your Markdown is kept, as in a .svelte file. For Markdown that users write, use content.Render, which escapes HTML.

#Images

Add ?image to an image import and Tern resizes it to several widths in AVIF, WebP and its own format. Show it with <Image>, which renders a <picture> with the right sources, its width and height (so the page does not shift while it loads) and lazy loading:

<script lang="ts">
	import { Image } from '@ternkit/tern';
	import hero from '$lib/assets/hero.jpg?image';
</script>

<Image src={hero} alt="The team at work" sizes="(min-width: 1024px) 50vw, 100vw" />
  • By default the widths are 640, 1280 and 1920 pixels, those smaller than the image, plus the image's own width. Choose others with ?w=400,800&image.
  • sizes tells the browser how wide the image is shown, so it downloads the smallest file that is sharp enough. Without it, the browser assumes the image's full width.
  • priority loads the image at once with high priority; use it for the main image above the fold. Other images load lazily.
  • Images with transparency keep PNG as the fallback format; others use JPEG.

Remote images. <Image> also takes the URL of an image on another site, with the width and height it is shown at:

<Image src="https://images.unsplash.com/photo-123" width={800} height={533} alt="A lake" />

The plugins/images plugin resizes them on the fly for the srcset, and caches the results:

kit.WithPlugins(images.New(images.Options{Hosts: []string{"images.unsplash.com"}}))
  • What it fetches: only HTTPS images on the listed hosts (*.example.com matches subdomains), up to 20 MB and 50 megapixels.
  • Sizes: images are never enlarged.
  • Formats: remote images are re-encoded as JPEG, or PNG when they have transparency, because Go has no pure-Go AVIF or WebP encoder.

Images are encoded with sharp, which you install in your app: npm install -D sharp. In development each size is encoded the first time the browser asks for it. Encoded files are cached in node_modules/.cache/tern-images, so builds only encode new or changed images. Their names come from the image's contents, and they are served with the other hashed assets, cached for a year.

#Translations

Put one JSON file per locale in src/locales, such as en.json and de.json. Nest keys as you like; t() uses dotted paths:

{
	"login": { "title": "Sign in", "invalid": "Enter a name of up to {max} characters." },
	"projects": { "count": "{count, plural, =0 {No projects} one {# project} other {# projects}}" }
}

Translate in components with t from @ternkit/tern. tern gen types its keys from the default locale, so a typo is a type error:

<script lang="ts">
	import { t } from '@ternkit/tern';
	let { data } = $props();
</script>

<h1>{t('login.title')}</h1>
<p>{t('projects.count', { count: data.projects.length })}</p>

Messages use a subset of ICU MessageFormat: {name} placeholders, plural (with =N cases, CLDR categories such as one, few and many, and # for the number) and select. Quote literal braces with apostrophes: '{'. Go and the browser share one implementation, tested against the same cases, so server-rendered text always hydrates.

Choosing the locale. For each request Tern uses the first of:

  1. a lang route parameter, as in src/routes/lang.optional/…
  2. the locale cookie
  3. the best match for the Accept-Language header
  4. the default locale: "en" if there is an en.json, else the first file, or "i18n": { "defaultLocale": "de" } in tern.json

The locale fills %tern.lang% in app.html (<html lang="%tern.lang%">). Only the current locale's messages, and the default locale's for fallbacks, are sent to the browser. A message missing from a locale falls back to the default locale; tern gen warns about missing keys.

Translated URLs. With the locale in the URL (src/routes/lang.optional/projects), each locale can use its own words. List them under "$routes" in its file:

{ "$routes": { "projects": "projekte", "settings": "einstellungen" } }
  • Matching: /de/projekte reaches the same route as /projects, with lang set to de. /de/projects works too.
  • Links: route('/[[lang]]/projects', { lang: 'de' }) builds /de/projekte.
  • What's translated: only the words of the route directories, not parameter values.

In Go, ctx.Locale() returns the locale and ctx.T translates, for example for validation errors returned with kit.Fail:

return nil, kit.Fail(400, Invalid{Error: ctx.T("login.invalid", map[string]any{"max": 40})})

ctx.SetLocale overrides the locale for the rest of the request.

Switching languages. setLocale('de') stores the choice in the locale cookie and reloads the page's data without a full page load. i18n.locale and i18n.locales list the current and available locales for a switcher. If the locale is in the URL instead, link to the other URL.

Tern's own texts. Tern UI's labels ("Show password", "Next page", …) and the validation messages of kit.FormAction and validate() are messages too, under keys that start with tern., with English built in. Translate them in your locale files like your own; whatever a locale leaves out stays English:

{
	"tern": {
		"ui": { "password": { "show": "Passwort anzeigen" }, "pagination": { "next": "Weiter ›" } },
		"validate": { "required": "{field} ist ein Pflichtfeld." }
	}
}

The same keys translate on the server and in the browser, so translated validation messages match wherever they are checked. A label you pass a component as a prop, such as <Pagination label="Seiten">, wins over the built-in text. The validation messages get {field} (the field's label), {fieldLower}, and per rule {count}, {n}, {date}, {options} or {otherLower}.

Every built-in text
Key English
tern.ui.alertDialog.cancel Cancel
tern.ui.alertDialog.confirm Continue
tern.ui.barChart.total Total
tern.ui.breadcrumb.label Breadcrumb
tern.ui.calendar.nextMonth Next month
tern.ui.calendar.previousMonth Previous month
tern.ui.carousel.label Slides
tern.ui.carousel.next Next slide
tern.ui.carousel.previous Previous slide
tern.ui.chart.legend Legend
tern.ui.combobox.empty No results
tern.ui.combobox.searching Searching…
tern.ui.command.empty No results
tern.ui.command.label Commands
tern.ui.command.placeholder Type a command or search…
tern.ui.consent.allow Allow
tern.ui.consent.deny Deny
tern.ui.consent.notYou Not you?
tern.ui.consent.note You can take this back at any time in your account settings.
tern.ui.consent.scopes It will be able to:
tern.ui.consent.title {client} wants to access your account
tern.ui.dataTable.actions Actions
tern.ui.dataTable.empty No rows match
tern.ui.dataTable.search Search…
tern.ui.dataTable.searchLabel Search {caption}
tern.ui.dataTable.selectAll Select all rows on this page
tern.ui.datePicker.label Choose a date
tern.ui.datePicker.placeholder Pick a date
tern.ui.datePicker.placeholderRange Pick dates
tern.ui.dialog.close Close
tern.ui.donutChart.total total
tern.ui.navigationMenu.label Main
tern.ui.pagination.label Pagination
tern.ui.pagination.next Next ›
tern.ui.pagination.nextLabel Next page
tern.ui.pagination.previous ‹ Previous
tern.ui.pagination.previousLabel Previous page
tern.ui.password.show Show password
tern.ui.resizable.label Resize
tern.ui.selectMenu.placeholder Choose…
tern.ui.sidebar.label Main
tern.ui.sidebar.open Open navigation
tern.ui.sidebar.title Navigation
tern.ui.signIn.cancelled You cancelled the sign-in. Try again when you’re ready.
tern.ui.signIn.continueWith Continue with {provider}
tern.ui.signIn.email Email
tern.ui.signIn.failed Signing in didn’t work. Try again, or choose another way.
tern.ui.signIn.forgot Forgot your password?
tern.ui.signIn.new New here?
tern.ui.signIn.or or
tern.ui.signIn.password Password
tern.ui.signIn.remember Keep me signed in
tern.ui.signIn.signUp Create an account
tern.ui.signIn.submit Sign in
tern.ui.signIn.title Sign in
tern.ui.signUp.agree I agree to the
tern.ui.signUp.confirm Confirm password
tern.ui.signUp.email Email
tern.ui.signUp.haveAccount Already have an account?
tern.ui.signUp.minPassword At least {n} characters.
tern.ui.signUp.name Name
tern.ui.signUp.password Password
tern.ui.signUp.signIn Sign in
tern.ui.signUp.submit Create account
tern.ui.signUp.terms terms
tern.ui.signUp.title Create an account
tern.ui.spinner.label Loading
tern.ui.table.empty Nothing to show
tern.ui.toaster.dismiss Dismiss
tern.ui.toaster.label Notifications
tern.validate.after {field} must be on or after {date}.
tern.validate.before {field} must be on or before {date}.
tern.validate.checked {field} must be checked.
tern.validate.date {field} must be a date.
tern.validate.datetime {field} must be a date and time.
tern.validate.email {field} must be an email address.
tern.validate.exactly {field} must be exactly {count}.
tern.validate.int {field} must be a whole number.
tern.validate.items Choose exactly {count} for {fieldLower}.
tern.validate.length {field} must be exactly {count} {n, plural, one {character} other {characters}}.
tern.validate.max {field} must be at most {count}.
tern.validate.maxItems Choose at most {count} for {fieldLower}.
tern.validate.maxLength {field} must be at most {count} {n, plural, one {character} other {characters}}.
tern.validate.min {field} must be at least {count}.
tern.validate.minItems Choose at least {count} for {fieldLower}.
tern.validate.minLength {field} must be at least {count} {n, plural, one {character} other {characters}}.
tern.validate.number {field} must be a number.
tern.validate.oneof {field} must be one of {options}.
tern.validate.or or
tern.validate.pattern {field} isn't in the expected format.
tern.validate.required {field} is required.
tern.validate.same {field} must match {otherLower}.
tern.validate.url {field} must be a web address starting with http:// or https://.

#Service workers

Add src/service-worker.ts and Tern builds it to /service-worker.js and registers it in production. The $service-worker module tells it what this build contains:

Export Contents
build URLs of the hashed JavaScript, CSS and asset files
files URLs of the files in static/
prerendered URLs of the prerendered pages
version a string that changes with every build, for naming caches
base the base path
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, prerendered, version } from '$service-worker';

const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE = `app-${version}`;

sw.addEventListener('install', (event) => {
	event.waitUntil(caches.open(CACHE).then((c) => c.addAll([...build, ...files, ...prerendered])));
});

examples/todos/src/service-worker.ts is a complete offline-first worker: cached assets and prerendered pages, the network first for everything else, and the cache when offline.

The service worker is built after prerendering, so its lists are complete. It is not registered in development, where a caching worker would serve stale modules; test it with tern build. To register it yourself, set "serviceWorker": { "register": false } in tern.json.

#Logging

Log with log/slog. A logger from logging.New adds the request's ID to records logged in a request, redacts passwords, tokens and other secrets, can sample floods, and ships records to OpenTelemetry, Loki, Datadog or rotating files in the background:

logger := logging.New(logging.Options{Sinks: []logging.Sink{otlp.New(otlp.Options{})}})
slog.SetDefault(logger)
server, err := ternApp(kit.WithLogger(logger))

In loads and actions, slog.InfoContext(c, …) and c.Logger().Info(…) include the request's ID, which the response carries as X-Request-Id. TERN_LOG_LEVEL and TERN_LOG_FORMAT set the level and format. Under tern dev, records and requests show up live at /__tern/dev/logs. See docs/logging.md.

kit.WithTracer traces each request, with its loads, actions, render, SQL queries and outgoing calls, exported over OTLP; under tern dev, the log view shows each request's waterfall. See docs/tracing.md. kit.WithReporter groups unexpected errors and panics, on the server and in the browser, and sends them to Sentry; see docs/errors.md.

#Configuration file

Tern works without configuration. An optional tern.json at the project root holds the few settings that the Go server, the code generator and Vite must agree on:

{
	"basePath": "/app",
	"i18n": { "defaultLocale": "en" },
	"serviceWorker": { "register": true }
}

i18n.defaultLocale chooses the default locale (see Translations).

basePath serves the app under a URL prefix, for example behind a proxy that routes /app/* to it. With it set:

  • the app answers at /app, /app/projects and so on, and / redirects to /app
  • asset URLs, %tern.assets% in app.html, and route() links include the prefix
  • kit.Redirect(303, "/login") redirects to /app/login; use ctx.Path("/login") for other links built in Go
  • links outside the prefix are full page loads

Write links with route() from $routes and href="%tern.assets%/favicon.svg" in app.html, so the app works with or without a base path.

#Commands

tern new starts a project. Without a directory, it asks its questions, starting with a bare app or a full one; with one, it uses flags:

  • -template: minimal (the default), blog for Markdown posts with an RSS feed, api for typed JSON endpoints with OpenAPI, or saas for a full app: accounts with passkeys, two-step sign-in and sign-in with GitHub or Google, teams with invitations and roles, a dashboard, settings, and per-team data in SQLite with a migration, styled with Tern UI. Its README lists the settings it reads from the environment.
  • -ui: styles the starter with Tern UI.
  • -git: creates a git repository with a first commit.
  • -install: runs npm install.
  • npm create tern@latest: runs the matching tern new, for people who start from npm.

tern dev reads a .env file in the project, if there is one, into the app's environment: KEY=value lines, # comments, quoted values. Variables already set win over it, and empty values are skipped. tern build's binary reads only the real environment.

Run the CLI with go tool tern, which uses the version pinned in your go.mod:

Command Purpose
tern dev [-port 5173] [-host localhost] development server: hot module replacement for Svelte, automatic rebuilds and reloads for Go, build errors shown in the browser
tern build [-o build/app] production binary with the client, server renderer and prerendered pages embedded
tern deploy [target] deploy to a server over SSH, to Fly.io or to a container registry, as tern.json says; -rollback, -releases, -logs (see deployment)
tern gen regenerate tern.gen.go, the client manifest, types and typed queries
tern check [svelte-check options] generate, then type-check with svelte-check, including the markup of Go components
tern mcp serve the Model Context Protocol for AI coding tools
tern doctor [-json] [-offline] [target] check the project before a deploy: tools and versions, code generation, native rendering, migrations, known vulnerabilities, security options, and the target's environment and HTTPS (deployment)
tern migrate new <name>, tern migrate check create a migration; check them for changes that fail or break a deploy (migrations)
tern fix access after upgrading, mark routes without an Access rule public (who may use a route)
tern add [-n] <plugin>... add plugins to main.go, by name or Go package; without arguments, list them
tern lsp serve the language server for editors, which the VS Code and Zed extensions start
tern routes [-json] [dir] list routes and who may use each; -json prints them for scripts and editors
tern new [-template minimal|blog|api|saas] [-ui] [-git] [-install] [dir] create a project; asks when no directory is given
tern ui add <Component>... copy Tern UI components into src/lib/ui to change them; tern ui list lists them
tern version print the version

For deployment, see deployment.md.