Development

Write a SvelteKit app

A working browser app that authenticates with Trellis and calls RPCs.

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

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_capabilities even 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

  1. Update the defineContract(...) module
  2. Rebuild or verify with trellis contracts build / trellis contracts verify --source
  3. Run the SvelteKit app with npm run dev
  4. Sign in through the login flow — approve the app if prompted
  5. Confirm RPCs and UI work as expected