Search the site
Find pages, services and blog posts
Skip to content
All posts

Running a Large SvelteKit App in Production: Permissions, Auth, SQL and Zero-Downtime Deploys

  • svelte
  • sveltekit
  • architecture
  • security
  • deployment
  • teams

The short version: a large SvelteKit app stays manageable in production when authorisation lives in one place and compiles down to SQL, auth and data access sit on the same plain Postgres pool, and deploys are boring because every migration is additive and the old container stays up until the new one is healthy. Svelte itself is rarely the hard part.

That’s the pattern I’ve landed on from client work on a multi-tenant workflow platform. Several organisations use it, each with their own teams, roles and data they must never see from each other. I can’t name the client, and the snippets below are fresh examples written for this post, not their code. The problems and the fixes are real though, and they come up on almost every SvelteKit project I’m asked to look at once it gets past a few dozen routes.

Permissions: one engine, rules as data

The first version of permissions on most apps is a role check sprinkled through route files. if (user.role === 'admin') in a load function, another in a form action, a third in a component to hide a button. It works until someone asks for “editors can only see documents in their own team” or “viewers can update their own profile but not their role”. Then the checks multiply and drift.

What replaced it was a small attribute-based access control (ABAC) engine. Rules are plain objects built per user at request time:

1type Rule = {
2	action: 'view' | 'update' | 'delete';
3	resource: 'document' | 'team';
4	condition?: Record<string, string | number>;
5	fields?: string[];
6};
7
8function document_rules(subject: Subject): Rule[] {
9	if (subject.role === 'admin') {
10		return [
11			{ action: 'view', resource: 'document' },
12			{ action: 'update', resource: 'document' },
13		];
14	}
15
16	if (subject.role === 'editor') {
17		return subject.team_ids.flatMap((team_id) => [
18			{
19				action: 'view',
20				resource: 'document',
21				condition: { team_id },
22			},
23			{
24				action: 'update',
25				resource: 'document',
26				condition: { team_id, owner_id: subject.user_id },
27				fields: ['title', 'body', 'status'],
28			},
29		]);
30	}
31
32	return [];
33}

Default deny is the empty array at the bottom. No catch-all, no fallback role.

From that list the engine answers three questions. can(action, resource, row) checks a single row. permitted_fields(action, resource) returns the allow-list for writes, so a crafted form post that sets owner_id gets that key stripped before it reaches the database. And to_sql_where(action, resource) turns the matching conditions into a parameterised WHERE clause.

That last one is the bit I’d push on any team. If list queries fetch everything and filter in JavaScript, you’ll eventually ship a page that forgets to filter. When the rules compile to SQL, out-of-scope rows never leave Postgres:

1export function emit_where(rules: Rule[], start_index = 1) {
2	if (rules.some((r) => !r.condition))
3		return { sql: 'TRUE', params: [] };
4	if (rules.length === 0) return { sql: 'FALSE', params: [] };
5
6	const params: unknown[] = [];
7	const clauses = rules.map((rule) => {
8		const parts = Object.entries(rule.condition!).map(
9			([key, value]) => {
10				params.push(value);
11				return `${column_for(key)} = $${start_index + params.length - 1}`;
12			},
13		);
14		return `(${parts.join(' AND ')})`;
15	});
16
17	return { sql: clauses.join(' OR '), params };
18}

column_for maps attribute names to an allow-listed set of column names, so nothing user-supplied ends up in the SQL string.

404, not 403

When a user asks for a row they can’t see, they get a 404. Same response as a row that doesn’t exist. A 403 tells an attacker the ID is real, which makes IDs enumerable and lets someone probe where the tenant boundaries are. 403 is kept for cases where the message is the point, like a feature gated behind a plan upgrade.

The service layer owns that decision. Route files and remote functions stay thin and never ask “does this exist?” separately from “can this user see it?“:

1export async function update_document(input: UpdateDocumentInput) {
2	const perms = await get_permissions();
3	const existing = await documents.get_by_id(perms.org_id, input.id);
4
5	if (!existing || !perms.can('update', 'document', existing)) {
6		error(404, 'Not found');
7	}
8
9	const allowed = perms.permitted_fields('update', 'document');
10	return documents.update(
11		perms.org_id,
12		input.id,
13		pick(input, allowed),
14	);
15}

The data-access layer never imports the permissions engine. It takes an org ID, concrete filters and the generated WHERE spec. That separation makes it easy to grep for mistakes, and I do: a CI check that fails if anything in the data layer imports permission code, or if a service throws a 403.

The UI doesn’t import the engine either. The server load computes booleans like can_edit and passes them through page.data, and components read those. Hiding a button is UX. The service check is the security boundary.

Auth: from Auth.js to Better Auth

The app started on Auth.js with an ORM adapter. We moved to Better Auth, mainly because it talks to a plain pg Pool with no ORM in the way, and because the things we needed (magic links, TOTP, org membership) are first-party plugins rather than hand-rolled.

The setup in SvelteKit is small:

1// src/lib/server/auth.ts
2import { betterAuth } from 'better-auth';
3import { sveltekitCookies } from 'better-auth/svelte-kit';
4import { getRequestEvent } from '$app/server';
5import { pool } from '$lib/server/db/pool';
6
7export const auth = betterAuth({
8	database: pool,
9	plugins: [sveltekitCookies(getRequestEvent)],
10});

Then hooks.server.ts calls auth.api.getSession() once per request and puts the user and session on locals. Route code reads locals.user directly, which is simpler than awaiting locals.auth() everywhere.

A few things caught us out during the migration:

  • The tables don’t line up. Auth.js and Better Auth store roughly the same data with different names and shapes. Session tokens, expiry columns and the email-verified flag all change, and passwords move from the user row to the account row. The migration is a hand-written SQL file, run on staging with a copy of real data before it goes anywhere near production.
  • Everyone has to log in again. The session format changes. Tell users before, not after.
  • Set the base URL explicitly. If BETTER_AUTH_URL is missing in a container, magic links get generated against the wrong host. They send fine and then fail with “link expired” when clicked, which looks like a timing bug and isn’t.
  • Keep the secret stable. Rotating it between deploys logs everyone out.

Raw SQL over a pg Pool

No ORM. Every query is parameterised SQL through one shared pg Pool, and the result types are hand-written TypeScript interfaces.

1export async function list_documents(
2	org_id: string,
3	where: { sql: string; params: unknown[] },
4) {
5	const { rows } = await pool.query<DocumentRow>(
6		`SELECT id, title, status, team_id, owner_id, updated_at
7		 FROM documents
8		 WHERE org_id = $1 AND (${where.sql})
9		 ORDER BY updated_at DESC`,
10		[org_id, ...where.params],
11	);
12	return rows;
13}

(The permission spec is generated with start_index = 2 so its placeholders follow the org ID.)

The case for it on a large app is mostly about visibility. When a page is slow you can read the exact query, paste it into EXPLAIN ANALYZE and fix it. The permission WHERE clause composes with it naturally. And the Docker image loses the ORM’s engine binaries and generate step, which were the source of more than one build failure.

The conventions that keep it sane:

  • $1, $2 placeholders always. No string interpolation of values.
  • Money stored as integer pennies.
  • Migrations are numbered, append-only SQL files.
  • Postgres views for the handful of joins the UI needs everywhere, rather than repeating them across services.

Data tables with thousands of rows

Most screens in an app like this are tables: search, filter by status, sort, paginate, click through to a detail page. We built one shared data-table component with a typed state class behind it, and column definitions kept in their own file per page. New list pages are mostly configuration now.

Two lessons from getting it fast.

First, stream the data. Return the promise from the server load without awaiting it, so navigation is instant and the table fills in behind a skeleton.

Second, and this one is Svelte 5 specific: $state arrays are deeply proxied. A $derived that calls .filter() or .map() over a few thousand rows creates proxies for every row and every property it touches. On a big list that can freeze the page for seconds, and the cause is often a single innocent-looking derived count. Keep large datasets out of deep reactivity ($state.raw, or a plain array owned by the table state class) and compute filtered views imperatively when inputs change.

Zero-downtime deploys on ECS Fargate

The app runs as containers on AWS ECS Fargate behind a load balancer. The first deploys had a few seconds of 503s every time, and the cause was a default:

1aws ecs update-service \
2  --cluster "$CLUSTER" \
3  --service "$SERVICE" \
4  --health-check-grace-period-seconds 60 \
5  --deployment-configuration '{
6    "minimumHealthyPercent": 100,
7    "maximumPercent": 200,
8    "deploymentCircuitBreaker": { "enable": true, "rollback": true }
9  }'

With one task and a minimum healthy percent of 0, ECS stops the old container before starting the new one. At 100/200 the new task starts alongside the old one, has to pass the /health check, takes traffic, and only then does the old one drain. The circuit breaker rolls back automatically if the new task never goes healthy. If your infrastructure-as-code module sets its own defaults, check them after every apply, or it can quietly put the bad values back.

Migrations run in the pipeline before the new code serves traffic. We’ve used both a one-off task running the new image and migrations applied on boot before the server starts listening. Either works. What matters more is the rule that goes with it: every migration must be safe for the previous version of the code, because during a rollout both versions are live against the same database.

Adding a table, a nullable column or an index is fine. Renaming or dropping a column is not, so it’s split across two deploys. The first adds the new column and writes to both. The second, once nothing reads the old column, removes it.

1- name: Run migrations
2  run: pnpm db:migrate
3  env:
4    DATABASE_URL: ${{ secrets.DATABASE_URL }}
5
6- name: Deploy
7  run: ./scripts/deploy.sh

If migrations fail, the deploy stops and the old containers carry on serving.

Tests next to the code

Component tests are co-located with the components as *.svelte.test.ts and run in a real Chromium through Vitest browser mode. I’ve written about why before, and on a big app it pays off every week. Server and permission tests run in Node alongside them.

The tests I’d insist on for any multi-tenant app are the permission ones:

  • one unit test per role branch asserting the exact rules it emits;
  • the edge case where a user belongs to no teams and should get an empty rule set;
  • an end-to-end test for every new resource where a user from another organisation requests it by ID and gets a 404, not a 403 and not a leaked count.

That last one is cheap to write and it tests the thing that would be a data breach.

Skeletons and page transitions

Two small things that make a large app feel quicker than it is.

Loading states are skeletons that mirror the real layout: same number of cards, same table header, same toolbar. A spinner followed by a page that jumps into place feels broken. A skeleton that matches the content feels like the data was nearly there already. The rule we follow is that any change to a page’s layout updates its skeleton in the same PR.

Route changes use the View Transitions API through SvelteKit’s onNavigate, set up once in the root layout:

1<script lang="ts">
2	import { onNavigate } from '$app/navigation';
3
4	onNavigate((navigation) => {
5		if (!document.startViewTransition) return;
6
7		return new Promise((resolve) => {
8			document.startViewTransition(async () => {
9				resolve();
10				await navigation.complete;
11			});
12		});
13	});
14</script>

Only the main content area gets a view-transition-name, so the sidebar and header stay put while the page underneath fades across. Browsers without support just navigate normally.

What I’d tell a team starting out

If you’re building something similar, the order I’d tackle it in:

  1. Put authorisation behind one engine early, and make list queries use it through SQL. Retrofitting it later means auditing every route.
  2. Return 404 for anything a user isn’t allowed to see.
  3. Pick your auth library and your data-access approach together. A plain pg Pool shared by both keeps the stack small.
  4. Make migrations additive by policy, and fix your deployment config so the old container outlives the new one’s health check.
  5. Write the cross-tenant 404 test before you need it.

None of this is exotic. It’s the stuff that decides whether a SvelteKit app is still pleasant to work on after a year in production.

Working on something like this?

I’m a Svelte consultant based in the UK, and this is the kind of work I do with teams: SvelteKit architecture, permissions and security reviews, migrations, and getting deploys to the point where nobody is nervous about them. If your app has grown past its first set of decisions, get in touch.