This tutorial walks through building a SvelteKit app that authenticates with Trellis and calls RPCs from the browser. By the end you will have a working app with login, auth error handling, and authenticated data fetching.
Before starting, read Trellis Concepts for background on contracts, auth, and packages.
What you need
- a running Trellis environment from Starting Trellis
- Node installed
- the
trellisCLI installed (see Install the Trellis CLI)
1. Create the project
npx sv create my-app
cd my-app Install the Trellis packages:
npm install @qlever-llc/trellis-contracts @qlever-llc/trellis-svelte @qlever-llc/trellis @qlever-llc/trellis-sdk-auth Then add the generated SDK packages for any Trellis services your app calls. For example, if your app reads activity data:
npm install @qlever-llc/trellis-sdk-activity 2. Define the app contract
Create src/lib/contracts/my_app.ts. For apps, the key difference from services is kind: "app":
import { defineContract } from "@qlever-llc/trellis-contracts";
import { auth } from "@qlever-llc/trellis-sdk-auth";
export const myApp = defineContract({
id: "my-app@v1",
displayName: "My App",
description: "A browser app that reads auth state.",
kind: "app",
uses: {
auth: auth.use({
rpc: { call: ["Auth.Me"] },
}),
},
}); The uses block is what Trellis analyzes when deriving the subject permissions granted to the signed-in browser session. If you need to call RPCs from other services, add them here.
3. Build the login page
Create .env for local development:
VITE_TRELLIS_AUTH_URL=http://localhost:3000
VITE_TRELLIS_NATS_SERVERS=ws://localhost:8080 Create src/routes/login/+page.svelte:
<script lang="ts">
import { createAuthState } from "@qlever-llc/trellis-svelte";
import { page } from "$app/state";
import { myApp } from "$lib/contracts/my_app";
const auth = createAuthState({
authUrl: import.meta.env.VITE_TRELLIS_AUTH_URL,
loginPath: "/login",
contract: myApp,
});
function targetUrl(): string {
const path = page.url.searchParams.get("redirectTo") ?? "/dashboard";
return window.location.origin + path;
}
async function signIn() {
await auth.signIn(undefined, targetUrl());
}
</script>
<h1>Sign in</h1>
<button onclick={signIn}>Continue to sign in</button> createAuthState(...) manages the browser session key and includes the app contract in the login flow. The callback URL is the destination page directly — TrellisProvider handles the auth token fragment wherever it lands.
4. Wrap authenticated routes with TrellisProvider
Create a layout for the protected part of the app at src/routes/(app)/+layout.svelte:
<script lang="ts">
import { TrellisProvider, type BindErrorResult } from "@qlever-llc/trellis-svelte";
import { myApp } from "$lib/contracts/my_app";
let { children } = $props();
let bindError = $state<BindErrorResult | null>(null);
function redirectToLogin(redirectTo: string): void {
window.location.href = `/login?redirectTo=${encodeURIComponent(redirectTo)}`;
}
</script>
{#if bindError}
<p>
{#if bindError.status === "approval_denied"}
Access was denied.
{:else if bindError.status === "approval_required"}
This app requires approval before it can access Trellis.
{:else if bindError.status === "insufficient_capabilities"}
Your account is missing required capabilities: {bindError.missingCapabilities.join(", ")}
{:else}
Authentication failed.
{/if}
</p>
<a href="/login">Try again</a>
{:else}
<TrellisProvider
authUrl={import.meta.env.VITE_TRELLIS_AUTH_URL}
natsServers={[import.meta.env.VITE_TRELLIS_NATS_SERVERS ?? "ws://localhost:8080"]}
serviceName="my-app"
loginPath="/login"
contract={myApp}
onAuthRequired={redirectToLogin}
onBindError={(result) => { bindError = result; }}
>
{#snippet loading()}
<p>Connecting…</p>
{/snippet}
{@render children()}
</TrellisProvider>
{/if} TrellisProvider restores the auth session, handles the OAuth callback fragment on any protected page, connects to NATS, creates the Trellis client, and exposes all three through Svelte context. The loading snippet renders while the connection is being established. onBindError is called when the callback completes but authentication cannot proceed.
The natsServers prop is a bootstrap default only — once authenticated, Trellis auth returns an instance-specific list that TrellisProvider uses instead.
5. Call RPCs
Call RPCs directly from a page at src/routes/(app)/dashboard/+page.svelte:
<script lang="ts">
import { getTrellisFor } from "@qlever-llc/trellis-svelte";
import { myApp } from "$lib/contracts/my_app";
const trellis = await getTrellisFor(myApp);
const me = await trellis.requestOrThrow("Auth.Me", {});
</script>
<h1>Welcome, {me.user.name}</h1> Top-level await in Svelte 5 makes the component async — it suspends until all awaits resolve before rendering, so no loading state is needed. getTrellisFor(myApp) keeps the Trellis client typed from your app contract, and requestOrThrow(...) unwraps the Result<T, E> boundary for component code.
6. Build and verify contract artifacts
Keep the contract source in your app repo and generate artifacts into build output:
my-app/
src/
lib/
contracts/my_app.ts
routes/
login/+page.svelte
(app)/+layout.svelte
(app)/dashboard/+page.svelte
dist/contracts/ Build and verify it:
trellis contracts build \
--source ./src/lib/contracts/my_app.ts \
--out-manifest ./dist/contracts/trellis.my-app@v1.json
trellis contracts verify --source ./src/lib/contracts/my_app.ts 7. Understand the approval lifecycle
App approval works differently from service installation:
- Services are installed by an operator for a specific service identity
- Apps are approved per-user during sign-in for a specific contract digest
- If the app contract changes (new digest), users must re-approve
- If the user lacks required capabilities, bind fails with
insufficient_capabilitieseven though OAuth login succeeded - If the user explicitly denies the approval page, the app receives
approval_denied
This means contract changes in browser apps should be treated as access changes, not just UI changes.
Development loop
- Update the
defineContract(...)module - Rebuild or verify with
trellis contracts build/trellis contracts verify --source - Run the SvelteKit app with
npm run dev - Sign in through the login flow — approve the app if prompted
- Confirm RPCs and UI work as expected