🗾

モダンフレームワークSvelteKitでのコロケーションができるようになったので紹介する

に公開

こんにちは、株式会社エクスプラザの@_mkazutakaです。
今回は、SvelteKitを使ったコロケーションの実現方法について紹介します。

背景

2025年の8月にSvelteKit v2.27.でRemoteFunctionがリリースされました。
https://svelte.dev/blog/whats-new-in-svelte-august-2025
https://github.com/sveltejs/kit/releases/tag/%40sveltejs%2Fkit%402.27.0

これは簡単にいうと、あらゆるコンポーネントからサーバサイド上の関数を呼ぶことができる機能になっています。これによって今までページごとにデータを取得し、コンポーネントに引き渡す、ようなデータのバケツリレーをせずとも、コンポーネント単位で必要な情報をサーバサイドから取得することができます(いわゆるコロケーションってやつですね)。

これによってコードの見通しがよくなったりし、複数人での開発するに際してもコンフリクトが少なくなったり、親コンポーネントのことを考えずに実装が進めれたりと、さまざまなメリットがあるので個人的には割と着目していた機能です。

というわけで今回は、このRemnoteFunctionを使ったコロケーションの実装方法について紹介したいと思います。

RemoteFunctionとは

あらためて、公式の文章を翻訳し、引用すると以下のような形です。

リモート関数は、クライアントとサーバー間で型安全にデータをやり取りするための機能です。アプリケーションの任意の場所で呼び出すことは可能ですが、実際の処理は常にサーバー側で実行されます。これにより、環境変数やデータベースクライアントなど、サーバー専用のモジュールに安全にアクセスすることが可能です。
https://svelte.dev/docs/kit/remote-functions

詳細な実装方法についてはここでは触れませんが、公式ドキュメントおよび以下の記事の解説が非常に参考になりますので、ぜひご覧ください。

https://svelte.dev/docs/kit/remote-functions
https://azukiazusa.dev/blog/sveltekit-remote-functions/

コロケーションをやってみる

実際にRemoteFunctionのqueryとform関数を使ったコロケーションの実現方法を紹介します。

1. プロジェクトの作成 / 初期設定

とりあえず、プロジェクトの作成をします。SvelteKitは、svコマンドを通して空プロジェクトを作成することができます。

$ npx sv create colocation-demo

◇  Which template would you like?
│  SvelteKit minimal
│
◇  Add type checking with TypeScript?
│  Yes, using TypeScript syntax
│
◆  Project created
│
◇  What would you like to add to your project? (use arrow keys / space bar)
│  prettier, eslint, tailwindcss
│
◇  tailwindcss: Which plugins would you like to add?
│  none
│
◆  Successfully setup add-ons
│
◇  Which package manager do you want to install dependencies with?
│  pnpm

プロジェクト作成後、Devサーバを起動し、HelloWorldが表示されるか確認してください。
ついでに、shadcn-svelteも導入しておきます(Doc)。

pnpm dlx shadcn-svelte@latest init
pnpm dlx shadcn-svelte@latest add button dialog card input label textarea
// svelte.config.jsも編集

あとZodもいれておきます

$ pnpm add -D zod@^4.0.0

2. 実験的機能を有効にする

RemoteFunctionは現時点では実験段階なため、svelte.config.jsから有効にする必要があります。

diff --git a/svelte.config.js b/svelte.config.js
index 4309e68..74b285f 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -15,6 +15,14 @@ const config = {
                alias: {
                        "@/*": "./src/lib/*",
                },
+               experimental: {
+                       remoteFunctions: true
+               },
+       },
+       compilerOptions: {
+               experimental: {
+                       async: true
+               }
        }
 };

3. コンポーネントの準備

RemoteFunctionを使ったコロケーションの実装をする前に、コロケーションなしでの実装します。

今回は、例として投稿一覧と投稿追加できるダイアログの実装をします。それぞれ以下のようにしました。「新規投稿」ボタンを押すと、表示される投稿が増えると思います。

src/lib/store.ts
export type Post = {
	id: string;
	title: string;
	author: string;
	date: string;
	excerpt: string;
	tags: string[];
};

export const posts: Post[] = [
	{
		id: "1",
		title: 'Svelteで始めるモダンWeb開発',
		author: '田中太郎',
		date: '2024年9月15日',
		excerpt:
			'SvelteKitを使用した最新のWeb開発手法について解説します。コンポーネントベースの開発やリアクティブな状態管理など、効率的な開発を実現するための基礎知識を紹介します。',
		tags: ['Svelte', 'Web開発', 'フロントエンド']
	},
	{
		id: "2",
		title: 'TypeScriptの型システム入門',
		author: '佐藤花子',
		date: '2024年9月14日',
		excerpt:
			'TypeScriptの強力な型システムを活用して、より安全で保守性の高いコードを書く方法を学びましょう。基本的な型から高度な型の使い方まで幅広くカバーします。',
		tags: ['TypeScript', 'JavaScript', '型システム']
	},
	{
		id: "3",
		title: 'Tailwind CSSでUIを素早く構築',
		author: '鈴木一郎',
		date: '2024年9月13日',
		excerpt:
			'ユーティリティファーストのCSSフレームワーク、Tailwind CSSを使用して、美しく一貫性のあるUIを効率的に構築する方法を紹介します。',
		tags: ['CSS', 'Tailwind', 'デザイン']
	},
	{
		id: "4",
		title: 'shadcn/uiでコンポーネント開発',
		author: '高橋美香',
		date: '2024年9月12日',
		excerpt:
			'再利用可能でアクセシブルなUIコンポーネントライブラリ、shadcn/uiの導入方法と活用事例を紹介。プロジェクトの開発速度を大幅に向上させます。',
		tags: ['UI', 'コンポーネント', 'shadcn']
	},
	{
		id: "5",
		title: 'Git & GitHubワークフロー最適化',
		author: '山田次郎',
		date: '2024年9月11日',
		excerpt:
			'チーム開発を円滑に進めるためのGitワークフローのベストプラクティス。ブランチ戦略からプルリクエストの管理まで、実践的なテクニックを解説します。',
		tags: ['Git', 'GitHub', 'バージョン管理']
	},
	{
		id: "6",
		title: 'レスポンシブデザインの実装パターン',
		author: '伊藤真理',
		date: '2024年9月10日',
		excerpt:
			'モバイルファーストのアプローチで、あらゆるデバイスで美しく機能的なWebサイトを作成する方法。フレキシブルレイアウトとメディアクエリの効果的な使い方を学びます。',
		tags: ['レスポンシブ', 'モバイル', 'CSS']
	}
];
src/routes/+page.server.ts
import { posts, type Post } from '$lib/store';
import type { Actions, PageServerLoad } from './$types';
import { z } from 'zod';

const schema = z.object({
	title: z.string().min(1, '1文字以上入力してください'),
	author: z.string().min(1, '1文字以上入力してください'),
	excerpt: z.string().min(1, '1文字以上入力してください'),
	tags: z.string().optional()
});

export const load: PageServerLoad = async () => {
	return {
		posts
	};
};

export const actions: Actions = {
	default: async ({ request }) => {
		const formData = await request.formData();
		const data = Object.fromEntries(formData.entries());

		const result = schema.safeParse(data);

		if (!result.success) {
			console.log(result.error);
			return {
				success: false,
				errors: result.error.flatten().fieldErrors
			};
		}

		const newPost: Post = {
			id: Math.random().toString(36).substring(2),
			title: result.data.title,
			author: result.data.author,
			date: new Date().toLocaleDateString('ja-JP', {
				year: 'numeric',
				month: 'long',
				day: 'numeric'
			}),
			excerpt: result.data.excerpt,
			tags: result.data.tags
				? result.data.tags
						.split(',')
						.map((tag) => tag.trim())
						.filter(Boolean)
				: []
		};

		posts.push(newPost);

		return {
			success: true,
			post: newPost
		};
	}
};
src/routes/+page.svelte
<script lang="ts">
	import type { PageProps } from './$types';
	import PostList from '@/components/PostList.svelte';
	import PostDialog from '@/components/PostDialog.svelte';

	let { data }: PageProps = $props();
</script>

<div class="container mx-auto px-4 py-8">
	<div class="mb-8 flex items-center justify-between">
		<h1 class="text-3xl font-bold">投稿一覧</h1>
		<PostDialog />
	</div>
	<PostList posts={data.posts} />
</div>
src/lib/components/PostList.svelte
<script lang="ts">
	import * as Card from '$lib/components/ui/card';
	import type { Post } from '@/store';

	type Props = {
		posts: Post[];
	};
	let { posts }: Props = $props();
</script>

<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
	{#each posts as post (post.id)}
		<Card.Root class="transition-shadow duration-200 hover:shadow-lg">
			<Card.Header>
				<Card.Title class="text-xl">{post.title}</Card.Title>
				<Card.Description>
					<div class="flex items-center gap-2 text-sm text-muted-foreground">
						<span>{post.author}</span>
						<span>•</span>
						<time>{post.date}</time>
					</div>
				</Card.Description>
			</Card.Header>
			<Card.Content>
				<p class="line-clamp-3 text-muted-foreground">{post.excerpt}</p>
			</Card.Content>
			<Card.Footer>
				<div class="flex flex-wrap gap-2">
					{#each post.tags as tag (tag)}
						<span
							class="rounded-full bg-secondary px-2.5 py-0.5 text-xs font-medium text-secondary-foreground"
						>
							{tag}
						</span>
					{/each}
				</div>
			</Card.Footer>
		</Card.Root>
	{/each}
</div>

{#if posts.length === 0}
	<div class="py-12 text-center">
		<p class="text-muted-foreground">投稿がありません</p>
	</div>
{/if}

src/lib/components/PostDialog.svelte
<script lang="ts">
	import * as Dialog from '$lib/components/ui/dialog';
	import { Button } from '$lib/components/ui/button';
	import { Input } from '$lib/components/ui/input';
	import { Label } from '$lib/components/ui/label';
	import { Textarea } from '$lib/components/ui/textarea';

	let open = $state(false);
</script>

<Dialog.Root bind:open>
	<Dialog.Trigger>
		<Button>新規投稿</Button>
	</Dialog.Trigger>
	<Dialog.Portal>
		<Dialog.Overlay />
		<Dialog.Content class="sm:max-w-[600px]">
			<Dialog.Header>
				<Dialog.Title>新しい投稿を作成</Dialog.Title>
				<Dialog.Description>
					投稿の詳細を入力してください。すべての必須項目を入力後、「投稿する」をクリックしてください。
				</Dialog.Description>
			</Dialog.Header>

			<form method="POST">
				<div class="grid gap-4 py-4">
					<div class="grid gap-2">
						<Label for="title">タイトル *</Label>
						<Input id="title" name="title" placeholder="投稿のタイトルを入力" required />
					</div>

					<div class="grid gap-2">
						<Label for="author">著者名 *</Label>
						<Input id="author" name="author" placeholder="著者名を入力" required />
					</div>

					<div class="grid gap-2">
						<Label for="excerpt">概要 *</Label>
						<Textarea
							id="excerpt"
							name="excerpt"
							placeholder="投稿の概要を入力"
							rows={4}
							required
						/>
					</div>

					<div class="grid gap-2">
						<Label for="tags">タグ(カンマ区切り)</Label>
						<Input id="tags" name="tags" placeholder="例: Svelte, Web開発, フロントエンド" />
					</div>
				</div>

				<Dialog.Footer>
					<Dialog.Close>
						<Button type="button" variant="outline">キャンセル</Button>
					</Dialog.Close>
					<Button type="submit">投稿する</Button>
				</Dialog.Footer>
			</form>
		</Dialog.Content>
	</Dialog.Portal>
</Dialog.Root>

上記のコードのいまいちなところは、PostDialogコンポーネントが暗黙的にroutes/+page.server.tsのactionに依存しているところです。Svelteでフォームを扱う際は、こういったことがよくおきます。PostDialogにactionのパスを渡すこともできますが、暗黙的な部分が明示的になるだけで依存自体の解消はできなかったりします。

4. コロケーションでの実装をする

コロケーションでの実装をしていきます。投稿一覧画面から進めていきます。
PostList.svelteに対応するファイルをPostList.remote.tsを作成します

src/lib/components/PostList.remote.ts
import { query } from '$app/server';
import { posts } from '@/store';

export const getPosts = query(async () => {
	return posts;
});

コンポーネント側も修正をします。PostList.remote.tsからgetPostsをimportし、derived runeを通して値を取得します。

src/lib/components/PostList.svelte
diff --git a/src/lib/components/PostList.svelte b/src/lib/components/PostList.svelte
index 3d548fd..dd6a17d 100644
--- a/src/lib/components/PostList.svelte
+++ b/src/lib/components/PostList.svelte
@@ -1,11 +1,8 @@
 <script lang="ts">
        import * as Card from '$lib/components/ui/card';
-       import type { Post } from '@/store';
+       import { getPosts } from '@/components/PostList.remote';

-       type Props = {
-               posts: Post[];
-       };
-       let { posts }: Props = $props();
+       const posts = $derived(await getPosts());
 </script>

このままだと、以下のエラーがが出るので対応します。エラーの内容は、非同期処理をフロントエンド側で扱う場合、svelte:boundary要素でそのコンポーネントを囲み、かつpending時に表示されるコンポーネントの実装が必要があるというものです。

Cannot await outside a `<svelte:boundary>` with a `pending` snippet
src/routes/+page.svelte
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 1b129d8..d8d3a46 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,15 +1,18 @@
 <script lang="ts">
-       import type { PageProps } from './$types';
        import PostList from '@/components/PostList.svelte';
        import PostDialog from '@/components/PostDialog.svelte';
-
-       let { data }: PageProps = $props();
 </script>

-<div class="container mx-auto px-4 py-8">
-       <div class="mb-8 flex items-center justify-between">
-               <h1 class="text-3xl font-bold">投稿一覧</h1>
-               <PostDialog />
+<svelte:boundary>
+       <div class="container mx-auto px-4 py-8">
+               <div class="mb-8 flex items-center justify-between">
+                       <h1 class="text-3xl font-bold">投稿一覧</h1>
+                       <PostDialog />
+               </div>
+               <PostList />
        </div>
-       <PostList posts={data.posts} />
-</div>
+
+       {#snippet pending()}
+               <p>loading...</p>
+       {/snippet}
+</svelte:boundary>

以上で、投稿一覧取得部分は完了です。+page.svelteから投稿一覧の変数が消えているのがわかると思います。

Dialog側も実装していきましょう。PostDialog.remote.tsを作成します

$ pnpm add -D zod@^4.0.0
src/lib/components/PostDialog.remote.ts
import { z } from 'zod';
import { type Post, posts } from '@/store';
import { form } from '$app/server';

const schema = z.object({
	title: z.string().min(100, '1文字以上入力してください'),
	author: z.string().min(1, '1文字以上入力してください'),
	excerpt: z.string().min(1, '1文字以上入力してください'),
	tags: z.string().optional()
});

export const savePost = form(schema, async ({ title, author, excerpt, tags }) => {
	const newPost: Post = {
		id: Math.random().toString(36).substring(2),
		title: title,
		author: author,
		date: new Date().toLocaleDateString('ja-JP', {
			year: 'numeric',
			month: 'long',
			day: 'numeric'
		}),
		excerpt: excerpt,
		tags: tags
			? tags
					.split(',')
					.map((tag) => tag.trim())
					.filter(Boolean)
			: []
	};

	posts.push(newPost);

	return {
		success: true,
		post: newPost
	};
});

コンポーネントも修正します。

src/lib/components/PostDialog.svelte
diff --git a/src/lib/components/PostDialog.svelte b/src/lib/components/PostDialog.svelte
index 328ba32..a02b2a9 100644
--- a/src/lib/components/PostDialog.svelte
+++ b/src/lib/components/PostDialog.svelte
@@ -4,6 +4,7 @@
        import { Input } from '$lib/components/ui/input';
        import { Label } from '$lib/components/ui/label';
        import { Textarea } from '$lib/components/ui/textarea';
+       import { savePost } from '@/components/PostDialog.remote';

        let open = $state(false);
 </script>
@@ -22,16 +23,26 @@
                                </Dialog.Description>
                        </Dialog.Header>

-                       <form method="POST">
+                       <form {...savePost} >
                                <div class="grid gap-4 py-4">
                                        <div class="grid gap-2">
                                                <Label for="title">タイトル *</Label>
                                                <Input id="title" name="title" placeholder="投稿のタイトルを入力" required />
+                                               {#if savePost.issues?.title}
+                                                       {#each savePost.issues.title as issue (issue.message)}
+                                                               <p class="text-sm text-red-600">{issue.message}</p>
+                                                       {/each}
+                                               {/if}
                                        </div>

                                        <div class="grid gap-2">
                                                <Label for="author">著者名 *</Label>
                                                <Input id="author" name="author" placeholder="著者名を入力" required />
+                                               {#if savePost.issues?.author}
+                                                       {#each savePost.issues.author as issue (issue.message)}
+                                                               <p class="text-sm text-red-600">{issue.message}</p>
+                                                       {/each}
+                                               {/if}
                                        </div>

                                        <div class="grid gap-2">
@@ -43,11 +54,17 @@
                                                        rows={4}
                                                        required
                                                />
+                                               {#if savePost.issues?.excerpt}
+                                                       {#each savePost.issues.excerpt as issue (issue.message)}
+                                                               <p class="text-sm text-red-600">{issue.message}</p>
+                                                       {/each}
+                                               {/if}
                                        </div>

                                        <div class="grid gap-2">
                                                <Label for="tags">タグ(カンマ区切り)</Label>
                                                <Input id="tags" name="tags" placeholder="例: Svelte, Web開発, フロントエンド" />
+
                                        </div>
                                </div>

以上で完成です。いいですね!PostDialogコンポーネントが暗黙的にroutes/+page.server.tsのactionに依存しなくなりました。

デメリット

現時点の実装には、以下のようなデメリットがあります。

  • RemoteFunctionの呼び出しの実装は、基本的にAPI Callと同じなので、N+1などに注意する

    • query.batchなどの検討をするとよいかと(Docs)
  • フロントエンド側でフォームのバリデーション処理をしたい場合、セルフ実装する必要がある

    • 今公式ドキュメントのformセクションみたら更新されてたので解決されているかも
    • バリデーション自体をSvelteKitに実装するという話もでているようです。(discussions)
  • RemoteFunction単位でAPIリクエストが発生するため、使うべきところはしっかり考える

    • RelayのFragmentColocationほどよしなにやってくれないので、多少のパフォーマンスに目をつぶりコードの書きやすさ重視のコロケーションにするか、ページ上でデータを一括取得をしてバケツリレーするか悩むところはありそう
  • 8月に出た機能でLLMなどが学習していないので、LLMはRemoteFunctionを書けない(はず..)
      * 学習たのみます

まとめ & 感想

SvleteKitを使ったコロケーションの実現方法を紹介しました。
コンポーネント単位で値の取得、書き込みが実現できるのでコードの見通しは良くなりました。

株式会社エクスプラザ

Discussion