Getting started
Guide
Everything a Tern app is made of, from routes to realtime.
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
- Routing
- Loading data
- Layouts
- Errors and redirects
- Form actions
- Endpoints
- Hooks, middleware and configuration
- Plugins
- Types
- Rendering
- Streaming
- Prerendering
- Typed links
- The client API
- Reloading data
- Static files and assets
- Service workers
- Configuration file
- Commands
#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
?/nameruns the"default"action. - The action's result, or the data passed to
kit.Fail, becomes the page'sformprop. Its type is generated from the actions and everykit.Failcall in the package. - Without JavaScript, the browser posts the form and Tern re-renders the page with
formset. - With
use:enhance, the form posts in the background. On success it resets the form and reruns the page's loads, then setsform. On failure it setsform. 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
formtag, else theirjsonname.labelnames them in messages ("Seats must be at least 1."), andmessagereplaces all of a field's messages. - Types: strings, numbers,
bool(checkboxes),time.Time(a date, or withform:"when,datetime"adatetime-localvalue),[]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:errorsby field, the postedvaluesto refill the form, and amessage. Password fields, and fields that must match them, are never sent back. - Errors from your code: return
kit.FieldError(field, message)orkit.FormError(message)from the action, or from aValidate() errormethod on the struct, to fail the same way. - In the browser:
tern genwrites the same rules to./$forms. Tern UI'sFormchecks them as the user types and before posting, with the same messages, and puts each error on itsField:
<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.JSONandctx.Textwrite common responses;ctx.Writer()gives you thehttp.ResponseWriterfor anything else.- A handler that writes nothing responds
204 No Content. HEADfalls back toGET, and unsupported methods get405with anAllowheader.- 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)andfunc(ctx *kit.Ctx, in In) (Out, error)are typed.func(ctx *kit.Ctx) errorstays 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
querytag, else theirjsonname. - 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.
- Query fields are named by their
- 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:
Tcan be any type, so a stream can send a struct that fills in as the model writes it, such as aDraftwith itsTitleandPointsso far, and the page shows each version as it comes. - Errors: an error before the first
Sendis an ordinary error response. After it, the stream ends with the error, whichstreamthrows as anApiError, with the status and the message users may see. A stream that ends without finishing, such as when the connection drops, throws too. - Stopping:
breakout of the loop, or callabort()on the stream, to stop it: the request is cancelled, and the endpoint's nextSendfails, so it stops working for nobody. - The wire: server-sent events, with each value's JSON as an event's data, and a
doneorerrorevent 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 ascurl -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:
URLandURLSearchParamsTextEncoderandTextDecoder(UTF-8),atobandbtoacrypto.getRandomValuesandcrypto.randomUUID, backed by Go'scrypto/randstructuredCloneEvent,CustomEvent,EventTarget,AbortControllerandAbortSignalperformance.now()andqueueMicrotaskconsole, which logs through the app's logger, andprocess.env.NODE_ENV- an
Intlthat formats likeen-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.
#Typed links
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.txtis/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.Hookin 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:enhancereruns every load: after an action,use:enhancefollows 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
statevalue, 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
ProviderandSubject. Use the email only ifEmailVerifiedis true. - Identity fields:
Identityalso carries the provider's claims and its OAuth token, for calling its API. - Proxies: behind a proxy that changes the host, set
URLto the app's public address. - With the
accountspackage: setAccounts: accinstead ofOnLogin, 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 itsclient_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.Bearerchecks scopes, not the audience, so an API that must accept only its own tokens checksFortoo. Resources on the issuer's origin are allowed, andOptions.Resourcesadds others. - Your own consent page: set
ConsentURLto a page of your app, and the plugin sends users there instead of its built-in page. The page's load returnsprovider.ConsentRequest(ctx), and Tern UI'sConsentScreenshows 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 everyprojects: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 NULLcolumn added without aDEFAULTto 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-- +downsections that don't undo them. - Warnings: dropping or renaming a table or column the running version may still use;
UPDATEorDELETEwithoutWHERE, andTRUNCATE; 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, whichmigrate.Downstops 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
MaxAttemptstimes. jobs.Delay(d)andjobs.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.Listandq.Retrylet 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.
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
Insecureis set, for local servers such as Mailpit. - Addresses are validated,
Bccrecipients 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/emailslists every template, and/__tern/emails/<Name>previews one with itspreviewprops. Add?textfor 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.Serveneeds aSigner, orPublic: truefor files anyone may see, such as a site's images: files are private unless you say otherwise. - Unsafe file types: files are sent with
nosniffand 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.Bytesis the same for binary data. - Typed SQL:
-- param phone sealed.Stringand-- column phone sealed.Stringin 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/contentis embedded in the binary and read once. Undertern devit is read from disk on every request, so edits show on reload.- Files with
draft: truein their front matter are left out in production but shown undertern 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. sizestells 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.priorityloads 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.commatches 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:
- a
langroute parameter, as insrc/routes/lang.optional/… - the
localecookie - the best match for the
Accept-Languageheader - the default locale:
"en"if there is anen.json, else the first file, or"i18n": { "defaultLocale": "de" }intern.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/projektereaches the same route as/projects, withlangset tode./de/projectsworks 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 |
|
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 |
|
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/projectsand so on, and/redirects to/app - asset URLs,
%tern.assets%inapp.html, androute()links include the prefix kit.Redirect(303, "/login")redirects to/app/login; usectx.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),blogfor Markdown posts with an RSS feed,apifor typed JSON endpoints with OpenAPI, orsaasfor 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: runsnpm install.npm create tern@latest: runs the matchingtern 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.