The fetch client returns standard promises, so it fits naturally into whatever async pattern your framework uses.
import { Suspense } from "solid-js";
import { createAsync, query, useParams } from "@solidjs/router";
import fetchClients from "_/fetch";
const { GET } = fetchClients["users/[id]"];
// wrap the fetch in query() so preload and createAsync share one cache key -
// both call getUser with the same arg and the result is fetched once
const getUser = query((id: string) => GET([id]), "user");
// SolidJS Router calls preload on navigation intent; read params from its arg
export const preload = ({ params }) => getUser(params.id);
export default function UserProfile() {
const params = useParams();
const user = createAsync(() => getUser(params.id));
return <Suspense>{user()?.name}</Suspense>;
}import { useLoaderData } from "react-router";
import fetchClients from "_/fetch";
const { GET } = fetchClients["users/[id]"];
// React Router passes { params, request } into the loader; read params the
// native way and pass them to the client as an array
export const loader = ({ params }) => GET([params.id]);
export default function UserProfile() {
const user = useLoaderData();
return <div>{user.name}</div>;
}<script lang="ts">
import fetchClients from "_/fetch";
const { GET } = fetchClients["users/[id]"];
// the loader is a module-level export, so it lives in a plain <script> block -
// <script setup> compiles to setup() and cannot hold ES exports
export const loader = ({ params }) => GET([params.id]);
</script>
<script setup lang="ts">
import { useLoaderData } from "_/use";
const user = useLoaderData();
</script>
<template>
<div>{{ user.name }}</div>
</template><script module lang="ts">
import fetchClients from "_/fetch";
const { GET } = fetchClients["users/[id]"];
// the loader lives in the module <script> block; the instance <script> can't hold ES exports
export const loader = ({ params }) => GET([params.id]);
</script>
<script lang="ts">
import { useLoaderData } from "_/use";
const user = useLoaderData();
</script>
<div>{user.name}</div>import fetchClients from "_/fetch";
import { useLoaderData } from "_/use";
const { GET } = fetchClients["users/[id]"];
export const loader = ({ params }) => GET([params.id]);
export const user = () => useLoaderData();
export const User = () => {
return <div>{user()?.name}</div>;
};
<User />Types flow through these abstractions - loaders, resources, hooks, and components automatically know the response shape from your API definition.
KosmoJS only owns the envelope - the typed fetch client and how requests cross the wire. Everything above it is the framework's own model: Solid's query/createAsync, React Router's loader and useLoaderData, Vue's useLoaderData, Svelte's useLoaderData, MDX's useLoaderData - each reads through its native patterns, with no proprietary abstraction layered on top.
The client is just a typed function that takes the params array you build and returns a promise; where and how you call it is entirely the framework's.
Isomorphic Fetch
The client returns standard promises, so the integrations above work under SSR too. When a fetch runs as part of rendering the page - Solid's preload/createAsync, React's loader - it runs on the server during SSR: the request goes to the API route in-process (the API server is bundled into the SSR bundle), skipping the network but still running the full validation and handler chain.
What matters is when the fetch fires. Loaders and preloaded resources run on the server; a fetch in useEffect/onMounted doesn't, since those don't run during SSR - that one fetches on the client after hydration, like a plain CSR app.
For requests that run during SSR, the server-rendered result is reused on hydration rather than refetched:
- Solid and React reuse it through their built-in hydration.
- Vue, Svelte, and MDX reuse it through their loader: the result is serialized into the page during SSR and read on the client before the loader would fetch, so the request made during SSR is not repeated.
Suspense Is Your Responsibility
Solid's createAsync (like createResource) suspends: it reports its pending state to the nearest <Suspense> boundary and propagates errors to the nearest <ErrorBoundary>. KosmoJS does not provide either for you - the generated App boilerplate renders its children directly, deliberately not wrapping the app in <Suspense>, because one app-wide boundary is an anti-pattern: any pending fetch anywhere collapses the whole page to a single fallback and unrelated async work shares one loading state.
Scope the boundary to the data component or a sensible subtree yourself:
import { Suspense } from "solid-js";
import { createAsync, useParams } from "@solidjs/router";
export default function UserProfile() {
const params = useParams();
const user = createAsync(() => getUser(params.id));
return (
<Suspense fallback={<div>Loading...</div>}>
<div>{user()?.name}</div>
</Suspense>
);
}React's loader/useLoaderData resolves before render and does not suspend, so it needs no boundary unless you reach for React.lazy or a promise-throwing use(). The same holds for Vue, Svelte, and MDX loaders - they resolve before render, so only Solid's createAsync needs a boundary in the common case. Wrapping the whole app in one boundary does work if you accept the tradeoff - it is your call, not a default KosmoJS makes for you. See Data Preloading for the full breakdown.