diff --git a/packages/components/src/components/SelectNext/Select.mdx b/packages/components/src/components/SelectNext/Select.mdx
index 6b73f606..1ef87b52 100644
--- a/packages/components/src/components/SelectNext/Select.mdx
+++ b/packages/components/src/components/SelectNext/Select.mdx
@@ -194,6 +194,18 @@ so the search is always shown.
+### Server search
+
+This example shows how to implement server search in SelectNext with `useAsyncList`. It manages the
+loading state, provides an `AbortSignal` for cancelling outdated requests, and supports pagination
+through `loadMore`.
+
+Selected options are kept between requests and pinned at the top of the list. During search, the
+pinned options are filtered locally, while the rest of the options are displayed exactly as
+returned by the server.
+
+
+
### Open
#### Default open
diff --git a/packages/components/src/components/SelectNext/Select.stories.tsx b/packages/components/src/components/SelectNext/Select.stories.tsx
index 94f922f4..7d72c24e 100644
--- a/packages/components/src/components/SelectNext/Select.stories.tsx
+++ b/packages/components/src/components/SelectNext/Select.stories.tsx
@@ -13,6 +13,7 @@ import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '../Button';
import { FlexBox } from '../FlexBox';
+import { useAsyncList, useFilter } from '../index';
import { Typography } from '../Typography';
import type { SelectNextProps as SelectProps } from './index.js';
@@ -571,6 +572,116 @@ export const SearchableMinOptionsThreshold: Story = {
},
};
+export const ServerSearch: Story = {
+ render: function Render() {
+ type Person = { name: string; isRetained?: boolean };
+ type PeopleResponse = { next: string | null; results: Person[] };
+
+ const savedNames = ['Luke Skywalker'];
+ const [hasMore, setHasMore] = useState(true);
+ const { contains } = useFilter({ sensitivity: 'base' });
+
+ const list = useAsyncList({
+ getKey: ({ name }) => name,
+ initialSelectedKeys: savedNames,
+ async load({ signal, filterText, cursor, selectedKeys }) {
+ const selectedNames =
+ selectedKeys === 'all' ? [] : [...selectedKeys].map(String);
+
+ const selectedSet = new Set(selectedNames);
+
+ const selectedItems = selectedNames.map((name) => ({
+ name,
+ isRetained: true,
+ }));
+
+ const url =
+ cursor ??
+ `https://swapi.py4e.com/api/people/?search=${encodeURIComponent(filterText ?? '')}`;
+
+ try {
+ const response = await fetch(url, { signal });
+
+ if (!response.ok) {
+ throw new Error(`Failed to load people: ${response.status}`);
+ }
+
+ const data: PeopleResponse = await response.json();
+
+ const results = data.results.filter(
+ ({ name }) => !selectedSet.has(name)
+ );
+
+ setHasMore(data.next != null);
+
+ return {
+ items: cursor == null ? [...selectedItems, ...results] : results,
+ cursor: data.next ?? undefined,
+ };
+ } catch (error) {
+ if (!signal.aborted) setHasMore(false);
+
+ throw error;
+ }
+ },
+ });
+
+ const value = list.selectedKeys === 'all' ? [] : [...list.selectedKeys];
+ const selectedSet = new Set(value.map(String));
+
+ // Retained items are client-side copies, not the selection source of truth.
+ // A deselected copy may remain until the next server response replaces it.
+ const retainedSet = new Set(
+ list.items.filter(({ isRetained }) => isRetained).map(({ name }) => name)
+ );
+
+ return (
+
+ );
+ },
+};
+
export const Open: Story = {
render: function Render() {
const [isOpen, { toggle, set }] = useBoolean(false);