diff --git a/app/people/page.tsx b/app/people/page.tsx
index bb21b27..9ca474a 100644
--- a/app/people/page.tsx
+++ b/app/people/page.tsx
@@ -1,10 +1,22 @@
import PeopleTable from "@/components/table/PeopleTable";
+import { fetchResourceData } from "@/lib/swapi/fetchResourceData";
+import { PeopleResource } from "@/lib/swapi/resources";
+import { SwapiResourceHydrator } from "@/lib/swapi/SwapiResourceHydrator";
+
+export default async function People() {
+ const entries = [];
+ try {
+ entries.push(await fetchResourceData(PeopleResource));
+ } catch (error) {
+ console.error("Failed to prefetch people for SSR:", error);
+ }
-export default function People() {
return (
People
-
+
+
+
);
}
diff --git a/app/planets/page.tsx b/app/planets/page.tsx
index cf2579a..783a225 100644
--- a/app/planets/page.tsx
+++ b/app/planets/page.tsx
@@ -1,10 +1,22 @@
import PlanetsTable from "@/components/table/PlanetsTable";
+import { fetchResourceData } from "@/lib/swapi/fetchResourceData";
+import { PlanetsResource } from "@/lib/swapi/resources";
+import { SwapiResourceHydrator } from "@/lib/swapi/SwapiResourceHydrator";
+
+export default async function Planets() {
+ const entries = [];
+ try {
+ entries.push(await fetchResourceData(PlanetsResource));
+ } catch (error) {
+ console.error("Failed to prefetch planets for SSR:", error);
+ }
-export default function Planets() {
return (
Planets
-
+
+
+
);
}
diff --git a/components/table/PlanetsTable.tsx b/components/table/PlanetsTable.tsx
index 34fa67b..a4840f2 100644
--- a/components/table/PlanetsTable.tsx
+++ b/components/table/PlanetsTable.tsx
@@ -15,7 +15,7 @@ export default function PlanetsTable() {
diff --git a/lib/fetch-store/createFetchStore.ts b/lib/fetch-store/createFetchStore.ts
index e83baca..7f319de 100644
--- a/lib/fetch-store/createFetchStore.ts
+++ b/lib/fetch-store/createFetchStore.ts
@@ -38,6 +38,16 @@ export type FetchStore = {
...args: Args
): UseResourceResult
;
};
+
+ /**
+ * Seeds the store with already-parsed data for a given URL.
+ * Called during render (e.g. by a hydrator component) so data is available
+ * for `useSyncExternalStore` during SSR and client hydration.
+ *
+ * Only writes when the entry is absent or stale — never overwrites fresh data.
+ * Does **not** notify subscribers (safe to call in the render phase).
+ */
+ preloadEntry: (url: string, data: unknown) => void;
};
// ---------------------------------------------------------------------------
@@ -165,12 +175,30 @@ export const createFetchStore = (): FetchStore => {
ResourceAction
>(reducer, {});
+ /**
+ * Directly seeds the store with parsed data for a URL.
+ * Only writes when the entry is absent or stale — never overwrites fresh data.
+ * Safe to call during render (no subscribers exist yet during SSR/hydration).
+ */
+ const preloadEntry = (url: string, data: unknown): void => {
+ const entry = getState()[url];
+ if (!entry || entry.state === "stale") {
+ dispatch({ type: "FETCH_SUCCESS", url, data });
+ }
+ };
+
const startFetch = async (
url: string,
parse: (raw: unknown) => unknown,
dispatch: (action: ResourceAction) => void,
force: boolean = false,
): Promise => {
+ // Skip when data was already loaded (e.g. preloaded from SSR) unless forced.
+ const current = getState()[url];
+ if (!force && current?.state === "success") {
+ return;
+ }
+
dispatch({ type: "FETCH_REQUEST", url });
const entry = getState()[url] as InternalResourceItem;
const meta = ensureMeta(entry);
@@ -254,7 +282,7 @@ export const createFetchStore = (): FetchStore => {
typeof def.url === "function" ? def.url(...args) : def.url;
const refetch = useCallback(
- (force: boolean = false) => {
+ (force: boolean = true) => {
if (resolvedUrl === null) return;
startFetch(resolvedUrl, def.parse, dispatch, force);
},
@@ -273,5 +301,5 @@ export const createFetchStore = (): FetchStore => {
return { ...(item ?? (STALE_ITEM as ResourceItem)), refetch };
}
- return { FetchProvider: Provider, useResource };
+ return { FetchProvider: Provider, useResource, preloadEntry };
};
diff --git a/lib/swapi/SwapiResourceHydrator.tsx b/lib/swapi/SwapiResourceHydrator.tsx
new file mode 100644
index 0000000..e33be40
--- /dev/null
+++ b/lib/swapi/SwapiResourceHydrator.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { type ReactNode, useRef } from "react";
+import { preloadSwapiEntry } from "@/lib/swapi/createSwapiStore";
+
+type HydrationEntry = { url: string; data: unknown };
+
+/**
+ * Preloads server-fetched data into the SWAPI fetch store during render.
+ *
+ * Wrap table (or any resource-consuming) components with this so the store
+ * already contains the data when they first render — both during SSR and
+ * client hydration.
+ *
+ * @example
+ * ```tsx
+ * const result = await fetchResourceData(PlanetsResource);
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function SwapiResourceHydrator({
+ entries,
+ children,
+}: {
+ entries: HydrationEntry[];
+ children: ReactNode;
+}) {
+ const hydrated = useRef(null);
+
+ if (hydrated.current == null) {
+ for (const { url, data } of entries) {
+ preloadSwapiEntry(url, data);
+ }
+ hydrated.current = true;
+ }
+
+ return <>{children}>;
+}
diff --git a/lib/swapi/createSwapiStore.ts b/lib/swapi/createSwapiStore.ts
index 23b9df3..67c6c8c 100644
--- a/lib/swapi/createSwapiStore.ts
+++ b/lib/swapi/createSwapiStore.ts
@@ -5,4 +5,5 @@ import { createFetchStore } from "@/lib/fetch-store/createFetchStore";
export const {
FetchProvider: SwapiFetchProvider,
useResource: useSwapiResource,
+ preloadEntry: preloadSwapiEntry,
} = createFetchStore();
diff --git a/lib/swapi/fetchResourceData.ts b/lib/swapi/fetchResourceData.ts
new file mode 100644
index 0000000..bfa8217
--- /dev/null
+++ b/lib/swapi/fetchResourceData.ts
@@ -0,0 +1,30 @@
+import type { ResourceDefinition } from "@/lib/fetch-store/types";
+
+/**
+ * Fetches and parses resource data on the server.
+ * Returns both the resolved URL and the parsed data so the caller can
+ * seed the client-side store for hydration.
+ *
+ * @example
+ * ```ts
+ * const { url, data } = await fetchResourceData(PlanetsResource);
+ * ```
+ */
+export async function fetchResourceData(
+ def: ResourceDefinition,
+): Promise<{ url: string; data: T }> {
+ const url = typeof def.url === "string" ? def.url : def.url();
+ if (!url) throw new Error("Resource URL resolved to null");
+
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(
+ `Failed to fetch ${url}: ${res.statusText} (HTTP ${res.status})`,
+ );
+ }
+
+ const raw = await res.json();
+ const data = def.parse(raw);
+
+ return { url, data };
+}