SvelteKit のページ遷移時のもっさり感を解消する4つの方法
はじめに
SvelteKit で開発していると, リンクをクリックしたときに遷移先の load 関数が完了するまでページが切り替わらず, もっさりした印象を受けることがあります.
これは SvelteKit のデフォルトの挙動で, データ取得が完了してからページ遷移を行うためです.
今回は, この問題を解消するための4つの方法を紹介します.
対策1. data-sveltekit-preload-data でホバー時に先読み
リンクにホバーした時点で遷移先の load を先に実行させることで, クリック時にはデータが揃っている状態にできます.
<!-- 個別のリンクに指定 -->
<a href="/heavy-page" data-sveltekit-preload-data="hover">Heavy Page</a>
<!-- body に付ければ全リンクに適用 -->
<body data-sveltekit-preload-data="hover">
"hover" の他に "tap"(タッチ開始時)もあります. モバイルではホバーが使えないので, "tap" を併用するとよいでしょう.
ちなみに, npx sv create でプロジェクトを作成した場合, app.html の <body> に data-sveltekit-preload-data="hover" がデフォルトで設定されています. すでに有効になっている可能性があるので, まずは自分のプロジェクトを確認してみてください.
ただし, ホバーしてすぐクリックした場合は先読みが間に合わないので, load の完了を待つことになります. load が重い場合はこれだけでは解消しきれないので, 他の対策と組み合わせるのがおすすめです.
対策2. ストリーミングで即遷移させる
load の返り値を Promise のまま(await せずに)返すと, ページ遷移を先に行い, データが届いたら表示を更新するという動きにできます.
// +page.server.js
export async function load({ fetch }) {
return {
// すぐ必要なデータは await する
quickData: await fetch('/api/quick').then(r => r.json()),
// 重いデータは await しない → Promise のまま渡す
heavyData: fetch('/api/slow').then(r => r.json())
};
}
<!-- +page.svelte -->
<script>
let { data } = $props();
</script>
<h1>{data.quickData.title}</h1>
{#await data.heavyData}
<p>読み込み中...</p>
{:then heavy}
<div>{heavy.content}</div>
{/await}
await しないで返した部分は, ページ遷移後に {#await} ブロックでローディング表示 → データ到着後に本表示, という流れになります.
SEO との使い分けに注意
Promise のまま返した部分は SSR 時に HTML に含まれないため, クローラーからは見えません.
-
SEO が必要なコンテンツ →
awaitして返す(記事本文, 商品情報, メタ情報など) - SEO が不要なコンテンツ → Promise のまま返す(コメント欄, 関連記事, レコメンドなど)
export async function load({ fetch }) {
// SEO重要 → await して SSR に含める
const article = await fetch('/api/article/123').then(r => r.json());
return {
article,
// SEO不要 → ストリーミング
comments: fetch('/api/article/123/comments').then(r => r.json()),
related: fetch('/api/article/123/related').then(r => r.json())
};
}
isDataRequest で SEO と UX を両立する
上の方法だとコメント欄などは常に SSR に含まれません. しかし isDataRequest を使えば, 初回アクセス(SSR)では await して SEO を確保しつつ, クライアント遷移時だけストリーミングにできます.
export async function load({ fetch, isDataRequest }) {
const article = await fetch('/api/article/123').then(r => r.json());
const commentsPromise = fetch('/api/article/123/comments').then(r => r.json());
return {
article,
// 初回SSR → await して HTML に含める / クライアント遷移 → ストリーミング
comments: isDataRequest ? commentsPromise : await commentsPromise
};
}
isDataRequest は, クライアントサイドナビゲーション時に true になります. これにより初回表示ではクローラーにも完全な HTML を返しつつ, ページ遷移時は即座に遷移する快適な UX を実現できます.
対策3. load 自体を軽くする
根本的な解決として load の処理を軽くすることも大事です. いくつか具体的な方法を紹介します.
Promise.all で並列化する
load 内で複数の fetch を順番に await していると, リクエストが直列になって遅くなります. Promise.all で並列化するだけで大きく改善できます.
// +page.server.js
// ❌ 直列: a が終わってから b を取得 → 合計時間が長い
const a = await fetch('/api/a').then(r => r.json());
const b = await fetch('/api/b').then(r => r.json());
// ✅ 並列: a と b を同時に取得 → 遅い方の時間で済む
const [a, b] = await Promise.all([
fetch('/api/a').then(r => r.json()),
fetch('/api/b').then(r => r.json())
]);
setHeaders で HTTP キャッシュを効かせる
SvelteKit の load では setHeaders で Cache-Control を設定できます. 頻繁に変わらないデータなら, キャッシュを設定することで毎回 API を叩かずに済みます.
// +page.server.js
export async function load({ fetch, setHeaders }) {
const articles = await fetch('/api/articles').then(r => r.json());
// 60秒間キャッシュ
setHeaders({
'Cache-Control': 'max-age=60'
});
return { articles };
}
このキャッシュはページ遷移時にも有効です. SvelteKit はクライアントサイドナビゲーション時に __data.json エンドポイントからデータを取得しますが, Cache-Control はそのレスポンスにも適用されるので, キャッシュが効いている間は再リクエストなしで即座に遷移できます.
開発時にキャッシュが邪魔になる場合は, ブラウザの DevTools で「Disable cache」を有効にすれば無視されます.
親レイアウトの load を活用する
親レイアウトで既に取得しているデータを子ページでも使いたい場合, await parent() で受け取れます. 同じ API を重複して叩かずに済むので, その分 load が軽くなります.
// +layout.server.js
export async function load({ fetch }) {
const user = await fetch('/api/user').then(r => r.json());
return { user };
}
// +page.server.js
export async function load({ parent }) {
const { user } = await parent();
// user を使った処理(再度 fetch しなくてよい)
return { userName: user.name };
}
他の対策と組み合わせることで, より快適な体験を実現できます.
対策4. navigating でローディング表示(おすすめ)
遷移中であることをユーザーに伝えるだけでも体感はかなり改善します. SvelteKit 2.12+ / Svelte 5 では $app/state から navigating を使います.
<!-- src/routes/+layout.svelte -->
<script>
import { navigating } from '$app/state';
let { children } = $props();
</script>
{#if navigating.to}
<div class="loading-bar" />
{/if}
{@render children()}
さらに, Next.js の loading.tsx のように「遷移クリック → ローディング表示 → load 完了 → 遷移先を表示」という動きにしたい場合は, レイアウトで子コンテンツを切り替えます.
<!-- src/routes/+layout.svelte -->
<script>
import { navigating } from '$app/state';
let { children } = $props();
</script>
{#if navigating.to}
<p>読み込み中...</p>
{:else}
{@render children()}
{/if}
この方法なら load 側は普通に await するだけで OK です. ページごとにローディングをカスタムしたい場合は, ルートグループでレイアウトを分けるとよいでしょう.
Next.js (App Router) との比較
React の Next.js (App Router) では loading.tsx を置くだけで遷移が先に行われるため, このもっさり感はデフォルトで起きにくくなっています.
| デフォルト挙動 | |
|---|---|
| SvelteKit | データ取得が完了してから遷移(もっさり感じやすい) |
| Next.js (App Router) | 遷移を先にして, loading.tsx を表示(即座に動いて見える) |
Next.js の App Router では loading.tsx を置くだけで Suspense ベースのローディングが有効になります.
app/
articles/[id]/
page.tsx ← async で重いデータ取得
loading.tsx ← 取得中に自動表示される
SvelteKit で同じ体験を実現するには, 対策4で紹介したレイアウトでの navigating による切り替えが最も近い方法です.
Svelte 5 での変更点
Svelte 5 では書き方がいくつか変わっています. この記事のコードは全て Svelte 5 の記法です.
| Svelte 4 | Svelte 5 |
|---|---|
export let data |
let { data } = $props() |
<slot /> |
{@render children()} |
import { navigating } from '$app/stores' |
import { navigating } from '$app/state' |
$navigating |
navigating.to(ストアではなくなった) |
$app/stores は SvelteKit 2.12 以降 deprecated です. Svelte 5 では $app/state を使いましょう.
おわりに
SvelteKit のページ遷移のもっさり感は, 原因を理解すれば解消しやすい問題です.
まずは 対策1のプリロードを試してみて, それでも気になる場合は 対策2のストリーミングや対策4のローディング表示を組み合わせるのがおすすめです.
個人的には, 対策4のレイアウトで navigating を使う方法が load を変更せずに全体に適用できるので手軽で気に入っています.
Discussion