DEV Community

Cover image for TanStack Start Basics Part 2 — Connecting to Your Directus Backend
Wade Thomas
Wade Thomas

Posted on

TanStack Start Basics Part 2 — Connecting to Your Directus Backend

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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',
      ],
    }),
  )
}
Enter fullscreen mode Exit fullscreen mode

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[]'.
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

Why does RootDocument take a children prop, and why wrap it? __root.tsx wraps your entire application — every route (About, Index, Products) gets passed in as children, rendered inside this HTML shell. Wrapping children with 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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 Products interface and a Schema
  • 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)