Our frontend has been static so far — just hardcoded headings on a few pages. Today we connect it to the Directus backend built earlier in this series, using the official Directus SDK to pull real product data into our TanStack Start app.
Installing the Directus SDK
There are a few ways to talk to Directus from a frontend — the REST API directly, GraphQL — but the SDK is the cleanest option, and what we're using here.
npm install @directus/sdk
Creating the Directus Helper
Inside src/lib, create a new file: directus.ts.
import { createDirectus, rest } from '@directus/sdk'
export const directus = createDirectus('Your_Directus_Project_URL').with(rest())
createDirectus initializes a connection client between your app and your Directus instance. On its own it starts empty — capabilities get added by chaining .with(). Since we're fetching over standard HTTP REST requests, we pair it with the rest() modifier.
Defining Our First Type
Inside src, create types/index.ts:
export interface Products {
id: string
name: string
brief: string
description: string
price: number
sale: number
quantity: number
isOnSale: boolean
category: string
image: string
}
Writing the Fetch Function
Back in directus.ts:
import { createDirectus, rest, readItems } from '@directus/sdk'
import type { Products } from '@/types'
export const directus = createDirectus('Your_Directus_Project_URL').with(rest())
export async function getProducts(): Promise<Products[]> {
return directus.request(
readItems('products', {
fields: [
'id',
'name',
'brief',
'description',
'price',
'sale',
'quantity',
'isOnSale',
'category',
'image',
],
}),
)
}
readItems is a built-in SDK helper that generates a request for fetching multiple items from a given collection. At this point, TypeScript throws:
Type 'Record<string, any>[]' is not assignable to type 'Products[]'.
Fixing the Schema Error
This happens because the SDK doesn't automatically know your database's actual shape — it needs a Schema type passed into createDirectus:
type Schema = {
products: Products[]
}
export const directus = createDirectus<Schema>('Your_Directus_Project_URL').with(rest())
This declares that a products collection exists and maps to an array of Products. With this in place, the error resolves — and as a bonus, TypeScript now validates and autocompletes the fields array against this type too.
Fetching the Data — Method 1: TanStack Query
Install it:
npm i @tanstack/react-query
Set it up in __root.tsx:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Create a client
const queryClient = new QueryClient()
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<Scripts />
</body>
</html>
)
}
Why does
RootDocumenttake achildrenprop, and why wrap it?__root.tsxwraps your entire application — every route (About, Index, Products) gets passed in aschildren, rendered inside this HTML shell. Wrappingchildrenwith the query provider means every nested route and component now has access to TanStack Query.
Now build the component, src/components/Products.tsx:
import { useQuery } from '@tanstack/react-query'
export default function Products() {
const { data: products, isLoading: loading } = useQuery({
queryKey: ['product'],
queryFn: getProducts,
staleTime: 5 * 60 * 1000,
gcTime: 15 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 2,
refetchOnMount: true,
})
if (loading) {
return <div className="container mx-auto p-8">Loading...</div>
}
return (
<div>
<div className="container mx-auto p-8">
<h1 className="text-4xl font-medium mb-4">Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products?.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
<h2 className="text-xl font-bold mb-2">{product.name}</h2>
</div>
))}
</div>
</div>
</div>
)
}
A quick rundown of the query options:
| Option | What it does |
|---|---|
staleTime |
How long fetched data stays "fresh" (5 min) before a refetch is considered |
gcTime |
How long unused data stays cached in memory (15 min) before being cleared |
refetchOnWindowFocus |
Whether switching tabs and back triggers a refetch (off here) |
retry |
Silent retry attempts on failure before giving up (2 here) |
refetchOnMount |
Whether the component checks for stale data and refetches on every mount (on here) |
Wire it into the route:
import Products from '@/components/Products'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/products')({
component: RouteComponent,
})
function RouteComponent() {
return (
<div className="p-8">
<h1 className="font-bold text-5xl text-slate-700">Products</h1>
<Products />
</div>
)
}
Visit localhost:3000/products — real product data, pulled live from Directus.
Fetching the Data — Method 2: Route Loaders
Loaders work a little differently, and only on routes directly — not on standalone components. Remove the Products component from the route and build it in directly:
import { createFileRoute } from '@tanstack/react-router'
import { getProducts } from '@/lib/directus'
export const Route = createFileRoute('/products')({
loader: async () => {
const products = await getProducts()
return products
},
component: RouteComponent,
})
function RouteComponent() {
const products = Route.useLoaderData()
return (
<div className="p-8">
<h1 className="font-bold text-5xl text-slate-700 mb-4">Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{products?.map((product) => (
<div key={product.id} className="border rounded-lg p-4">
<h2 className="text-xl font-bold mb-2">{product.name}</h2>
</div>
))}
</div>
</div>
)
}
The loader runs getProducts before the route renders, and Route.useLoaderData() pulls that resolved data straight into the component — no loading state needed, since the data's already there by the time the page shows up.
Which One Should You Use?
Both approaches call the exact same getProducts function underneath — the difference is where the fetching happens and how the result reaches your component.
TanStack Query shines for data that changes often or benefits from caching, background refetching, and built-in loading states — great for a snappy feel on repeat visits.
Route loaders fetch before the route even renders, so there's no loading spinner at all. That pairs particularly well with TanStack Start's server-side rendering model, since the data can be resolved as part of the initial render rather than after the fact.
Summary
In this part, we:
- Installed and configured the Directus SDK
- Typed our data with a
Productsinterface and aSchema - Built our first real fetch function using
readItems - Rendered a live products grid two different ways — TanStack Query and a route loader
Right now we're only displaying each product's name, but this same pattern carries directly into fuller product cards next — pricing, images, sale badges, and eventually branding and tags once those relational fields come into play.
Found this useful? Follow for more Directus and TanStack Start tutorials, or check out the video version above.
Top comments (0)