Displaying content in a React Router front end

You’ve configured your Studio with a post document type and learned how to query from your hosted dataset. Before deploying the Studio, let’s query and display this content on the front-end framework of your choice.

1Install a new React Router 8 (Remix) application

If you have an existing application, skip this first step and adapt the rest of the guide to install Sanity dependencies to fetch and render content.

Run the following in a new tab or window in your Terminal (keep the Studio running) to create a new React Router 8 application with Tailwind CSS and TypeScript.

You should now have your Studio and React Router 8 application in two separate, adjacent folders:

# outside your studio directory
npx create-react-router@latest react-router-hello-world -y
cd react-router-hello-world
├─ /react-router-hello-world
└─ /studio-hello-world

2Install Sanity dependencies

Run the following inside the react-router-hello-world directory to install:

# in react-router-hello-world
npm install @sanity/client @sanity/image-url @portabletext/react @tailwindcss/typography

3Start the development server

Run the following command and open http://localhost:5173 in your browser.

# in react-router-hello-world
npm run dev

4Configure the Sanity client

To fetch content from Sanity, you’ll first need to configure a Sanity Client.

Create a directory react-router-hello-world/app/sanity and within it create a client.ts file, with the following code:

import { createClient } from "@sanity/client";

export const client = createClient({
  projectId: "",
  dataset: "production",
  apiVersion: "2026-05-15",
  useCdn: false,
});

5Display content on the home page

React Router uses a loader function exported from routes for server-side fetching of data. Routes are configured in the app/routes.ts file.

The default home page can be found at app/routes/home.tsx

Update it to render a list of posts fetched from your Sanity dataset using the code below.

import type { SanityDocument } from "@sanity/client";
import { Link } from "react-router";
import { client } from "~/sanity/client";
import type { Route } from "./+types/home";

const POSTS_QUERY = `*[
  _type == "post"
  && defined(slug.current)
]|order(publishedAt desc)[0...12]{_id, title, slug, publishedAt}`;

export async function loader() {
  return { posts: await client.fetch<SanityDocument[]>(POSTS_QUERY) };
}

export default function IndexPage({ loaderData }: Route.ComponentProps) {
  const { posts } = loaderData;

  return (
    <main className="container mx-auto min-h-screen max-w-3xl p-8">
      <h1 className="text-4xl font-bold mb-8">Posts</h1>
      <ul className="flex flex-col gap-y-4">
        {posts.map((post) => (
          <li className="hover:underline" key={post._id}>
            <Link to={`/${post.slug.current}`}>
              <h2 className="text-xl font-semibold">{post.title}</h2>
              <p>{new Date(post.publishedAt).toLocaleDateString()}</p>
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

6Display individual posts

Create a new route for individual post pages.

The dynamic value of a slug when visiting /:slug in the URL is used as a parameter in the GROQ query used by Sanity Client.

Notice that we’re using Tailwind CSS Typography’s prose class to style the post’s body content. We installed @tailwindcss/typography in the dependencies step. Enable it by adding @plugin "@tailwindcss/typography"; to app/app.css below the existing @import "tailwindcss"; line.

Update the routes.ts configuration file to load this route when individual post links are clicked.

import { Link } from "react-router";
import { createImageUrlBuilder, type SanityImageSource } from "@sanity/image-url";
import type { SanityDocument } from "@sanity/client";
import { PortableText } from "@portabletext/react";
import type { Route } from "./+types/post";
import { client } from "~/sanity/client";

const { projectId, dataset } = client.config();
const urlFor = (source: SanityImageSource) =>
  projectId && dataset
    ? createImageUrlBuilder({ projectId, dataset }).image(source)
    : null;

const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]`;

export async function loader({ params }: Route.LoaderArgs) {
  return { post: await client.fetch<SanityDocument>(POST_QUERY, params) };
}

export default function Component({ loaderData }: Route.ComponentProps) {
  const { post } = loaderData;
  const postImageUrl = post.image
    ? urlFor(post.image)?.width(550).height(310).url()
    : null;

  return (
    <main className="container mx-auto min-h-screen max-w-3xl p-8 flex flex-col gap-4">
      <Link to="/" className="hover:underline">
        ← Back to posts
      </Link>
      {postImageUrl && (
        <img
          src={postImageUrl}
          alt={post.title}
          className="aspect-video rounded-xl"
          width="550"
          height="310"
        />
      )}
      <h1 className="text-4xl font-bold mb-8">{post.title}</h1>
      <div className="prose">
        <p>Published: {new Date(post.publishedAt).toLocaleDateString()}</p>
        {Array.isArray(post.body) && <PortableText value={post.body} />}
      </div>
    </main>
  );
}
import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"),
  route("/:slug", "routes/post.tsx"),
] satisfies RouteConfig;

Was this page helpful?

sanity.ioAll Systems Operational© Sanity 2026
Change site theme