Queries
The core idea behind Houdini’s data loading model is that queries live next to the routes that use them. A +page.gql file defines what data a route needs, and Houdini handles the fetching, caching, and prop-threading before your component ever renders.
Fetching starts at the moment navigation begins, not when the component mounts. This follows the render-as-you-fetch pattern recommended by the React docs: by the time your component renders for the first time, the data is already in the cache and arrives as props with no loading state needed.
Basic Usage
Define a query in +page.gql:
query ShowList { shows { id title }}The result arrives as a prop named after the query. Destructure it directly in the function signature:
export default function ShowsPage({ ShowList }) { return ( <ul> {ShowList.shows.map((show) => ( <li key={show.id}>{show.title}</li> ))} </ul> )}The prop name matches the query name exactly: ShowList, not data or props.ShowList. This isn’t just a convention. Houdini discovers which queries a route depends on by statically analyzing your component’s signature, so the query name must appear in the destructuring pattern. If you accept a generic props argument and reach for props.ShowList, Houdini won’t see the dependency and the data won’t be wired up.
Behind the Design: What About Query Colocation?
The prevailing wisdom is that queries should live inside the components that use them. Houdini agrees with the principle (we still colocate fragments in our component source, after all) but disagrees about where the boundary should sit.
The problem is timing. By the time JavaScript is loaded and runs, it’s too late to start a network request without a waterfall: navigate → download JS → parse → run component → discover query → fetch → render. The query has to live somewhere the router can find it before any JavaScript runs, outside the component tree entirely. Once you’re inside a JavaScript runtime, it’s too late.
The .gql file still lives next to the .tsx.jsx file. This is just colocation by directory rather than file.
Layout Queries
A +layout.gql works the same way as +page.gql, but its results are available to the layout component and every route nested beneath it.
query AppShell { viewer { name plan }}export default function RootLayout({ AppShell, children }) { return ( <div> <nav>{AppShell.viewer.name}</nav> {children} </div> )}All queries in the chain (the page query, the layout query, any ancestor layout queries) run in parallel before the route renders. Child pages don’t need to re-fetch this data either; it’s already in the cache from the layout’s load.
Query Variables
If a query variable name matches a route parameter, Houdini wires them automatically. Given a route at src/routes/shows/[id]/:
query ShowDetail($id: ID!) { show(id: $id) { title description }}The $id variable is populated from the [id] segment with no extra configuration.
Search Params
A nullable variable whose name doesn’t match a route parameter is populated from the query string, so a search route with an optional filter needs no extra wiring:
query ShowSearch($genre: String, $sort: SortOrder) { shows(genre: $genre, sort: $sort) { title }}Visiting /shows?genre=comedy runs the query with $genre set to "comedy" and $sort left null. Because the values come straight from the URL, changing the query string re-runs the query: navigating from /shows?genre=comedy to /shows?genre=drama refetches with the new value, exactly the way a route parameter would. Repeated keys fill list variables, so ?tag=a&tag=b lands in a [String!] variable as ["a", "b"].
Only nullable variables are eligible. A missing search param resolves to null, which means it can never turn a valid URL into a failing request. That restriction is also the reason the distinction shows up at build time. A required variable has to be satisfiable from the URL alone, so if a +page.gql declares a non-null variable that isn’t backed by a route segment and has no default, Houdini reports it during generation rather than letting the query fail later. The fix is one of three things: add the matching [segment], give the variable a default value, or make it nullable so it can ride in on the query string.
To construct these URLs with type safety rather than hand-writing query strings, the Link component takes a typed search prop derived from the destination route’s variables.
Imperative Handles
Sometimes you need to do more than display the initial data: trigger a refetch, load the next page, or respond to user input. The $handle prop gives you an imperative handle for the query:
export default function ShowsPage({ ShowList$handle }) { return ( <div> <ul> {ShowList$handle.data.shows.map((show) => ( <li key={show.id}>{show.title}</li> ))} </ul> <button onClick={() => ShowList$handle.fetch()}>Refresh</button> </div> )}If you’d rather avoid the .data accessor, destructure both props and use them side by side.
The methods available on the handle depend on the query’s directives. A query with @paginate will also have loadNextPage and loadPreviousPage. See Pagination for details. For the full handle API, see useQueryHandle.
TypeScript
Every route gets a generated ./$types module that exports typed props for that file. Import PageProps in a +page.tsx+page.jsx and your query results and handles are typed automatically. (Route params and search live on the generated PageRoute type, read via useRoute, so they can’t be accidentally destructured off the component props.)
import type { PageProps } from './$types'
export default function ShowsPage({ ShowList }: PageProps) { return ( <ul> {ShowList.shows.map((show) => ( <li key={show.id}>{show.title}</li> ))} </ul> )}Layout components use LayoutProps from the same module, which includes children alongside any layout query results:
import type { LayoutProps } from './$types'
export default function RootLayout({ AppShell, children }: LayoutProps) { return ( <div> <nav>{AppShell.viewer.name}</nav> {children} </div> )}Runtime Scalars
Runtime scalars let you define custom scalar types whose values are resolved at runtime (from the current session, request context, or any other source) rather than passed explicitly by the caller. This is useful for variables like the current user’s organization ID that are always available from session state and would otherwise need to be threaded through every query manually.
Define runtime scalars in your houdini.config.tshoudini.config.js under features.runtimeScalars:
export default { features: { runtimeScalars: { OrganizationFromSession: { type: 'ID', resolve: ({ session }) => session.organization } } }}The resolve function receives the same context as your client’s fetchParams, including session, metadata, and fetch.
Once defined, use the scalar as a variable type in any query. Houdini calls the configured resolve function automatically, so there’s no need to pass the value at the call site:
query OrganizationInfo($id: OrganizationFromSession!) { organization(id: $id) { name }}The scalar value is resolved fresh on every fetch, so if the session changes the next request picks up the new value automatically.
Deduplication
By default, nothing stops the same operation from running multiple times simultaneously. The @dedupe directive gives us control over that behavior:
query UserProfile($id: ID!) @dedupe { user(id: $id) { name }}With no arguments, @dedupe prevents a second request from firing if an identical one is already in-flight. To cancel the first request instead of dropping the second, pass cancelFirst: true:
query UserProfile($id: ID!) @dedupe(cancelFirst: true) { user(id: $id) { name }}The match argument controls what counts as "identical":
Operation: dedupe if any execution of this operation is pending, regardless of variables (default)Variables: dedupe only if the variables also matchNone: never dedupe