Build an app with Tanstack and Deno
Tanstack is a set of framework-agnostic data management tools. With Tanstack, developers can manage server state efficiently with Query, create powerful tables with Table, handle complex routing with Router, and build type-safe forms with Form. These tools work seamlessly across React, Vue, Solid, and other frameworks while maintaining excellent TypeScript support.
In this tutorial, we’ll build a simple app using Tanstack Query and Tanstack Router. The app will display a list of dinosaurs. When you click on one, it'll take you to a dinosaur page with more details.
Feel free to skip directly to the source code or follow along below!
Start with the backend API Jump to heading
Within our main directory, let's setup an api/
directory and create our
dinosaur data file, api/data.json
:
// api/data.json
[
{
"name": "Aardonyx",
"description": "An early stage in the evolution of sauropods."
},
{
"name": "Abelisaurus",
"description": "\"Abel's lizard\" has been reconstructed from a single skull."
},
{
"name": "Abrictosaurus",
"description": "An early relative of Heterodontosaurus."
},
...
]
This is where our data will be pulled from. In a full application, this data would come from a database.
⚠️️ In this tutorial we hard code the data. But you can connect to a variety of databases and even use ORMs like Prisma with Deno.
Secondly, let's create our Hono server. We start by
installing Hono from JSR with deno add
:
deno add jsr:@hono/hono
Next, let's create an api/main.ts
file and populate it with the below. Note
we'll need to import
@hono/hono/cors
and define
key attributes to allow the frontend to access the API routes.
// api/main.ts
import { Hono } from "@hono/hono";
import { cors } from "@hono/hono/cors";
import data from "./data.json" with { type: "json" };
const app = new Hono();
app.use(
"/api/*",
cors({
origin: "http://localhost:5173",
allowMethods: ["GET", "POST", "PUT", "DELETE"],
allowHeaders: ["Content-Type", "Authorization"],
exposeHeaders: ["Content-Type", "Authorization"],
credentials: true,
maxAge: 600,
}),
);
app.get("/", (c) => {
return c.text("Welcome to the dinosaur API!");
});
app.get("/api/dinosaurs", (c) => {
return c.json(data);
});
app.get("/api/dinosaurs/:dinosaur", (c) => {
if (!c.req.param("dinosaur")) {
return c.text("No dinosaur name provided.");
}
const dinosaur = data.find((item) =>
item.name.toLowerCase() === c.req.param("dinosaur").toLowerCase()
);
if (dinosaur) {
return c.json(dinosaur);
} else {
return c.notFound();
}
});
Deno.serve(app.fetch);
The Hono server provides two API endpoints:
GET /api/dinosaurs
to fetch all dinosaurs, andGET /api/dinosaurs/:dinosaur
to fetch a specific dinosaur by name
Before we start working on the frontend, let's update our deno tasks
in our
deno.json
file. Yours should look something like this:
{
"tasks": {
"dev": "deno --allow-env --allow-net api/main.ts"
}
// ...
}
Now, the backend server will be started on localhost:8000
when we run
deno task dev
.
Create Tanstack-driven frontend Jump to heading
Let's create the frontend that will use this data. First, we'll quickly scaffold a new React app with Vite using the TypeScript template in the current directory:
deno init --npm vite@latest --template react-ts ./
Then, we'll install our Tanstack-specific dependencies:
deno install npm:@tanstack/react-query npm:@tanstack/react-router
Let's update our deno tasks
in our deno.json
to add a command to start the
Vite server:
// deno.json
{
"tasks": {
"dev": "deno task dev:api & deno task dev:vite",
"dev:api": "deno --allow-env --allow-net api/main.ts",
"dev:vite": "deno -A npm:vite"
}
// ...
}
We can move onto building our components. We'll need two main pages for our app:
DinosaurList.tsx
: the index page, which will list out all the dinosaurs, andDinosaur.tsx
: the leaf page, which displays information about a single dinosaur
Let's create a new ./src/components
directory and, within that, the file
DinosaurList.tsx
:
// ./src/components/DinosaurList.tsx
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
async function fetchDinosaurs() {
const response = await fetch("http://localhost:8000/api/dinosaurs/");
if (!response.ok) {
throw new Error("Failed to fetch dinosaurs");
}
return response.json();
}
export function DinosaurList() {
const {
data: dinosaurs,
isLoading,
error,
} = useQuery({
queryKey: ["dinosaurs"],
queryFn: fetchDinosaurs,
});
if (isLoading) return <div>Loading...</div>;
if (error instanceof Error) {
return <div>An error occurred: {error.message}</div>;
}
return (
<div>
<h2 className="text-xl font-semibold mb-4">Dinosaur List</h2>
<ul className="space-y-2">
{dinosaurs?.map((dino: { name: string; description: string }) => (
<li key={dino.name}>
<Link
to="/dinosaur/$name"
params={{ name: dino.name }}
className="text-blue-500 hover:underline"
>
{dino.name}
</Link>
</li>
))}
</ul>
</div>
);
}
This uses
useQuery
from Tanstack Query to fetch and cache the dinosaur data automatically, with
built-in loading and error states. Then it uses
Link
from Tanstack Router to create client-side navigation links with type-safe
routing parameters.
Next, let's create the DinosaurDetail.tsx
component in the ./src/components/
folder, which will show details about a single dinosaur:
// ./src/components/DinosaurDetail.tsx
import { useParams } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
async function fetchDinosaurDetail(name: string) {
const response = await fetch(`http://localhost:8000/api/dinosaurs/${name}`);
if (!response.ok) {
throw new Error("Failed to fetch dinosaur detail");
}
return response.json();
}
export function DinosaurDetail() {
const { name } = useParams({ from: "/dinosaur/$name" });
const {
data: dinosaur,
isLoading,
error,
} = useQuery({
queryKey: ["dinosaur", name],
queryFn: () => fetchDinosaurDetail(name),
});
if (isLoading) return <div>Loading...</div>;
if (error instanceof Error) {
return <div>An error occurred: {error.message}</div>;
}
return (
<div>
<h2 className="text-xl font-semibold mb-4">{name}</h2>
<p>{dinosaur?.description}</p>
</div>
);
}
Again, this uses useQuery
from Tanstack Query to fetch and cache
individual dinosaur details, with
queryKey
including the dinosaur name to ensure proper caching. Additionally, we use
useParams
from Tanstack Router to safely extract and type the URL parameters defined
in our route configuration.
Before we can run this, we need to encapsulate these components into a layout.
Let's create another file in the ./src/components/
folder called Layout.tsx
:
// ./src/components/Layout.tsx
export function Layout() {
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">Dinosaur Encyclopedia</h1>
<nav className="mb-4">
<Link to="/" className="text-blue-500 hover:underline">
Home
</Link>
</nav>
<Outlet />
</div>
);
}
You may notice the
Outlet
component towards the bottom of our newly created layout. This component is from
Tanstack Router and renders the child route's content, allowing for nested
routing while maintaining a consistent layout structure.
Next, we'll have to wire up this layout with ./src/main.tsx
, which an
important file that sets up the Tanstack Query client for managing server state
and the Tanstack Router for handling navigation:
// ./src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { routeTree } from "./routeTree";
const queryClient = new QueryClient();
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</React.StrictMode>,
);
You'll notice we import
QueryClientProvider
,
which wraps the entire application to allow for query caching and state
management. We also import RouterProvider
, which connects our defined routes
to React's rendering system.
Finally, we'll need to define a
routeTree.tsx
file in our ./src/
directory. This file defines our application's routing
structure using Tanstack Router's type-safe route definitions:
// ./src/routeTree.tsx
import { RootRoute, Route } from "@tanstack/react-router";
import { DinosaurList } from "./components/DinosaurList";
import { DinosaurDetail } from "./components/DinosaurDetail";
import { Layout } from "./components/Layout";
const rootRoute = new RootRoute({
component: Layout,
});
const indexRoute = new Route({
getParentRoute: () => rootRoute,
path: "/",
component: DinosaurList,
});
const dinosaurRoute = new Route({
getParentRoute: () => rootRoute,
path: "dinosaur/$name",
component: DinosaurDetail,
});
export const routeTree = rootRoute.addChildren([indexRoute, dinosaurRoute]);
In ./src/routeTree.tsx
, we create a hierarchy of routes with Layout
as the
root component. Then we set two child routes, their paths and components — one
for the dinosaur list, DinosaurList
, and the other for the individual dinosaur
details with a dynamic parameter, DinosaurDetail
.
With all that complete, we can run this project:
deno task dev
Next steps Jump to heading
This is just the beginning of building with Deno and Tanstack. You can add persistent data storage like using a database like Postgres or MongoDB and an ORM like Drizzle or Prisma. Or deploy your app to AWS, Digital Ocean, or Google Cloud Run
You could also add real-time updates using Tanstack Query's refetching capabilities, implement infinite scrolling for large dinosaur lists, or add complex filtering and sorting using Tanstack Table. The combination of Deno's built-in web standards, tooling, and native TypeScript support, as well as Tanstack's powerful data management opens up numerous possibilities for building robust web applications.