# Add a feedback widget to a SvelteKit app

> Place the widget script in src/app.html with defer. Pass the signed-in user from a +layout.server.ts load function, then call identify in an $effect in the root layout. Set the Content Security Policy with kit.csp in svelte.config.js, so SvelteKit manages nonces and hashes for its own scripts.

Source: https://escutaproduto.com/resources/feedback-widget-sveltekit
Last updated: 2026-10-09

## Place the widget script in src/app.html

`src/app.html` is the template SvelteKit wraps around every page. Add the widget to its `head`, with `defer`, and keep the `%sveltekit.head%` placeholder where it is:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%sveltekit.assets%/favicon.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    %sveltekit.head%
    <script src="https://escutaproduto.com/widget.js" data-key="pk_your_product_key" defer></script>
  </head>
  <body data-sveltekit-preload-data="hover">
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>
```

Because `app.html` is shared by every route, the script loads once per full page load. SvelteKit's client router then takes over, and the widget is not requested again. Avoid placing the script in a `+page.svelte` file. That would add it per route and could load it twice on the same page.

The `data-key` is your public product key, which can only create feedback. It is safe in the HTML.

## Pass the user from a load function to the layout

Identify needs the signed-in user, and the usual place for it is the session your `hooks.server.ts` reads. The root layout's server load function returns only the fields the widget needs:

```ts
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from "./$types";

export const load: LayoutServerLoad = async ({ locals }) => {
  const user = locals.user
    ? { id: locals.user.id, email: locals.user.email, name: locals.user.name }
    : null;
  return { user };
};
```

Returning a small object instead of `locals.user` keeps password hashes, tokens and internal roles out of the page data. SvelteKit sends load data to the browser as serialized JSON, so names with quotes or angle brackets stay as data.

Set `locals.user` in `src/hooks.server.ts`, where your session lookup runs. Declare its type in `src/app.d.ts` so the layout compiles. The `getUserFromSession` function below stands in for your own session code:

```ts
// src/hooks.server.ts
import type { Handle } from "@sveltejs/kit";

export const handle: Handle = async ({ event, resolve }) => {
  const session = event.cookies.get("session");
  event.locals.user = session ? await getUserFromSession(session) : null;
  return resolve(event);
};
```

## Identify the user in the root layout

Read the data in the root layout and call identify from an effect. The effect runs in the browser only, which is where the widget lives:

```svelte
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import type { Snippet } from "svelte";
  import type { LayoutData } from "./$types";

  let { data, children }: { data: LayoutData; children: Snippet } = $props();

  $effect(() => {
    const user = data.user;
    if (!user) return;
    window.EscutaProduto = window.EscutaProduto || { q: [] };
    (window.EscutaProduto.q ||= []).push([
      "identify",
      [{ id: user.id, email: user.email, name: user.name }],
    ]);
  });
</script>

{@render children()}
```

The effect reads `data.user`, so it runs again whenever SvelteKit invalidates the layout data. That happens after login, after logout and after any action that calls `invalidateAll`. The ordinary case, a page navigation with the same user, does not re-run it.

Two details trip people up. First, the effect reads the root layout's data, so a user returned only from a page-level load is invisible to it. Either return the user from the layout, which every route inherits, or move the effect into that page. Second, `$effect` runs only in the browser, which is the only place the widget exists, so the identify call never runs during server rendering.

The global type for `window.EscutaProduto` goes in `src/app.d.ts`, inside the existing `declare global` block:

```ts
// src/app.d.ts
declare global {
  interface Window {
    EscutaProduto?: { q?: unknown[][] };
  }
}
export {};
```

## Open the form from a SvelteKit route

Svelte keeps attributes as written, so a button can preselect a type:

```svelte
<button type="button" data-escuta-open="bug">Report a bug</button>
```

For a floating button you do not want, add `data-trigger="none"` to the script tag in `app.html`. Valid types are `bug`, `idea`, `praise` and `other`. Because SvelteKit renders on the server first, the attribute is present in the initial HTML, and the widget wires it up after it loads.

## Client navigation in SvelteKit

SvelteKit intercepts clicks on internal links, fetches the next route's data and swaps components without a full document load. `window`, the widget and its queue all survive those swaps. Feedback sent from any route records the URL where the person was when they sent it.

If a link must reload the page, for example to leave the app for a non-SvelteKit route, add `data-sveltekit-reload` to that anchor. Use it sparingly. A full reload resets the page, and the widget loads again from the beginning.

## Set the Content Security Policy in svelte.config.js

SvelteKit can write your Content Security Policy for you, and it knows about its own inline scripts. Put the policy in `kit.csp` in `svelte.config.js`:

```js
// svelte.config.js
const config = {
  kit: {
    csp: {
      directives: {
        "script-src": ["self", "https://escutaproduto.com"],
        "connect-src": ["self", "https://escutaproduto.com"],
      },
    },
  },
};

export default config;
```

SvelteKit adds the quotes around keywords such as self, so write them bare in the array. In the default auto mode, prerendered pages get hashes for their inline scripts and server-rendered pages get nonces. You do not manage those values by hand.

Some guides put the policy in a `handle` hook instead. That works, but then your hook sets the header on every response, and you must keep SvelteKit's own inline scripts allowed yourself. Use the hook only if you already control every header. The [CSP guide for widgets](/resources/content-security-policy-widgets) explains what each directive covers.

## Verify the SvelteKit setup in the Escuta Produto inbox

1. Add `http://localhost:5173` (the default Vite dev URL that SvelteKit uses) to the allowed origins of your product.
2. Sign in, open the Feedback button and send a test bug.
3. In the inbox, check that the item shows your name and email, plus the page URL.
4. Navigate to another route with a normal link, send a second item and confirm the page URL changed without a reload.

If the name is missing, check that `data.user` is not null on the page where you tested. A layout load function that returns only `locals` fields may be returning null for a logged-out session.

The [widget reference](/docs/widget) lists every option. For a server-rendered app with a different framework, the [Astro guide](/resources/feedback-widget-astro) shows how to keep the widget across view transitions.

## Frequently asked questions

### Where do I add the feedback widget script in SvelteKit?

In src/app.html, inside the head element, with defer. SvelteKit renders that template for every page, so the script is present on the first response and stays loaded while people navigate client-side.

### How do I pass the signed-in user to identify in SvelteKit?

Return the user from a load function in +layout.server.ts, then read data.user in the root layout and call identify inside a $effect. SvelteKit serializes the data safely, so you do not escape it yourself.

### Should I set the Content Security Policy in hooks or in svelte.config.js?

Use kit.csp in svelte.config.js for the policy. SvelteKit then adds nonces or hashes for its own scripts. Set headers in a handle hook only when you already control every response header yourself.

### Does SvelteKit client navigation reload the feedback widget?

No. SvelteKit intercepts link clicks and swaps page components without reloading the document, so the widget stays loaded. Add data-sveltekit-reload to a link only when you need a full page load.
