Skip to content

Pastoria

A full-stack React framework for building data-driven apps with Relay, powered by Vite.


Pastoria is a React meta-framework with an emphasis on type-safety and using Relay for data. Relay has many advanced features for loading data that Pastoria natively enables such as partial data load, a normalized store, and optimistic mutations.

Pastoria uses a file-system based route definitions for pages rendered using React, and comes with a pre-configured GraphQL server. Pastoria doesn’t enforce a specific way to build a GraphQL schema, but the starter kits come bundled with Grats.

In addition to the built-in GraphQL API, Pastoria supports API routes for integration backend services like better-auth.

Creating a Pastoria App

Pastoria Templates

It's highly recommend to use Vite+ to create a new Pastoria app. Pastoria's Vite plugin is quite comprehensive, but Vite+ brings along just about everything else you could want.

sh
$ vp create @pastoria@latest

This will create a new fully ready Pastoria app complete with Relay, StyleX, a devserver, and a production setup.

Installing the Pastoria skill

Pastoria distributes a skill for agents using the skills CLI. Install the Pastoria skill with the following:

sh
$ vp dlx skills add rrdelaney/pastoria

Generating Code

Pastoria relies on a lot of code generation for the router to discover routes and be fully typed. This is automatically run on the development server, but can be manually run with:

Shell
$ pastoria

This will regenerate all Pastoria code, and can be safely run as many times as needed.

TIP

Run pastoria during CI, and make sure no files changed with

sh
$ git diff --exit-code --quiet

Page Routes

Pages are a UI defined by a React component rendered on a given route. Pastoria pages are defined in page.tsx files under the pastoria directory. Route variables are defined using [variable] directories. For example, the following files would map to pages:

FileRoute
pastoria/page.tsx/
pastoria/about/page.tsx/about
pastoria/user/[id]/page.tsx/user/:id

Pages must be named page.tsx. Other files in the pastoria directory will be ignored, excluding special files like app.tsx, environment.ts, and route.ts.

Page Components

Pages must default export a React component. This component will be used as the entry point to the page, and will be rendered as a child of the app component in app.tsx.

tsx
export default function Page() {
  return <p>Hello Pastoria!</p>;
}

Page components will be server rendered and then hydrated on the client on initial navigation, and only client-rendered on subsequent in-app navigation. Page components are not React server components.

Loading Data

Pastoria exclusively uses Relay to load data in page components. To define a query that a page component should load, create a query using Relay's usePreloadedQuery hook, then add it to an export Queries type:

tsx
import {page_GreetingQuery} from '#genfiles/queries/page_GreetingQuery.graphql.js';
import {graphql, usePreloadedQuery} from 'react-relay';

export type Queries = {
  greeting: page_GreetingQuery;
};

export default function Page({queries}: PastoriaPageProps<'/'>) {
  const {greeting} = usePreloadedQuery(
    graphql`
      query page_GreetingQuery @preloadable {
        greeting
      }
    `,
    queries.greeting,
  );

  return <p>{greeting}</p>;
}

Next run Relay's code generator to make the generated code available to you:

sh
$ relay-compiler && pastoria

Each query added to Queries will be fetched and supplied to the page component using the key defined in the object. A page can have any number of queries, and can even pass them to child components to load.

WARNING

It is possible to use a useEffect and fetch to load data in a page component, but it is highly discouraged. This data will not be present during server-side rendering and will cause slow loading for end users.

Route Variables

By default, Pastoria auto-generates a Zod schema from your page's GraphQL query variables. You can override this by exporting a schema constant from your page file. This is useful for optional search params, type coercion, or custom validation:

tsx
import {z} from 'zod/v4-mini';

export const schema = z.object({
  commander: z.pipe(z.string(), z.transform(decodeURIComponent)),
  tab: z.nullish(z.string()),
  sortBy: z.nullish(z.string()),
  minEventSize: z.nullish(z.coerce.number()),
});

Path parameters like commander above (from [commander]) are always required strings. Search parameters are typically z.nullish(...) since they may or may not be present in the URL. Use z.coerce.number() to parse numeric search params from the query string.

The parsed schema result is available as variables in getPreloadProps.

Customizing Loading

getPreloadProps controls how URL parameters map to query variables and which nested entrypoints to load. If not exported, Pastoria auto-generates one that passes all URL params directly to all queries.

Export it when you need to derive query variables, conditionally load entrypoints, or compute extra props:

tsx
export const getPreloadProps: GetPreloadProps<'/commander/[commander]'> = ({
  variables,
  queries,
  entryPoints,
}) => {
  const tab = variables.tab ?? 'entries';

  return {
    queries: {
      commanderShell: queries.commanderShell({
        commander: variables.commander,
      }),
    },
    entryPoints: {
      entries:
        tab === 'entries'
          ? entryPoints.entries({commander: variables.commander}) 
          : undefined, 
      staples:
        tab === 'staples'
          ? entryPoints.staples({commander: variables.commander}) 
          : undefined, 
    },
    extraProps: {
      tab,
      sortBy: variables.sortBy ?? 'TOP',
    },
  };
};

The queries and entryPoints arguments are factory functions. Call them with the variables needed for each query or entrypoint. Returning undefined for an entrypoint skips loading it entirely, which is the basis for conditional loading patterns like tabs.

Nesting Entrypoints

Nested entrypoints are lazily loaded sub-components. Any .tsx file in the pastoria/ directory that isn't page.tsx, app.tsx, or environment.ts becomes a nested entrypoint. They enable code-splitting: each entrypoint gets its own bundle and can preload its own queries independently.

To use entrypoints in a page, declare them with an EntryPoints type:

tsx
import {ModuleType} from '#genfiles/router/js_resource';
import {EntryPoint, EntryPointContainer} from 'react-relay';

export type EntryPoints = {
  entries?: EntryPoint<
    ModuleType<'/commander/[commander]#entries'>,
    ModuleParams<'/commander/[commander]#entries'>
  >;
  staples?: EntryPoint<
    ModuleType<'/commander/[commander]#staples'>,
    ModuleParams<'/commander/[commander]#staples'>
  >;
};

ModuleType is imported from the generated js_resource module. ModuleParams is a global type generated by Pastoria and does not need an import. The entrypoint ID uses # to separate the route path from the filename. For example, the file pastoria/commander/[commander]/entries.tsx has the ID /commander/[commander]#entries.

Render entrypoints using EntryPointContainer wrapped in Suspense:

tsx
{entryPoints.entries && (
  <Suspense fallback={<LoadingIcon />}>
    <EntryPointContainer
      entryPointReference={entryPoints.entries}
      props={{}}
    />
  </Suspense>
)}

Entrypoints are only rendered when their ref is defined. Combined with conditional loading in getPreloadProps, this enables patterns like tab-based navigation where only the active tab's code and data are loaded.

Runtime Props

Runtime props are values passed from a parent entrypoint container to a child component at render time. Pages automatically receive {pathname: string; searchParams: URLSearchParams} as runtime props, but nested entrypoints can define custom runtime props:

tsx
export type RuntimeProps = { 
  sortBy: string; 
}; 

export default function StapleDetails({
  queries,
  props, // {sortBy: string}
}: PastoriaPageProps<'/commander/[commander]#staple_details'>) {
  // ...
}

The parent passes runtime props through the props attribute of EntryPointContainer:

tsx
<EntryPointContainer
  entryPointReference={entryPoints.stapleDetails}
  props={{sortBy: 'TOP'}} 
/>

NOTE

Do not export RuntimeProps from a page.tsx. Pages always receive the default {pathname, searchParams} as their runtime props.

Extra Props

Extra props let you pass computed or derived data from getPreloadProps to the page component without needing a query. They're useful for passing normalized values back to the component so it doesn't need to re-derive them:

tsx
export type ExtraProps = { 
  sortBy: string;
  timePeriod: string;
  query: string;
};

export const getPreloadProps: GetPreloadProps<'/'> = ({variables, queries}) => ({
  queries: {
    commandersRef: queries.commandersRef(variables),
  },
  extraProps: { 
    sortBy: variables.sortBy ?? 'POPULARITY',
    timePeriod: variables.timePeriod ?? 'SIX_MONTHS',
    query: variables.q ?? '',
  },
});

export default function HomePage({queries, extraProps}: PastoriaPageProps<'/'>) { 
  // extraProps.sortBy, extraProps.timePeriod, extraProps.query
  // are the resolved values, no need to re-parse from URL
}

Pastoria generates type-safe navigation utilities from your route definitions. Import them from the generated router:

tsx
import {useNavigation, Link, RouteLink} from '#genfiles/router/router';

Programmatic Navigation

Use the useNavigation hook for imperative navigation:

tsx
const {push, replace, pushRoute, replaceRoute} = useNavigation();

// Type-safe: route ID + params object
pushRoute('/commander/[commander]', {commander: 'Kinnan, Bonder Prodigy'});

// Replace current history entry (good for filter changes)
replaceRoute('/', {sortBy: 'WIN_RATE', timePeriod: 'ONE_MONTH'});

pushRoute fills path parameters into the URL and puts remaining parameters into the query string. replaceRoute does the same but replaces the current history entry instead of pushing a new one — useful for filter or sort changes where you don't want each change to be a separate back-button stop.

For declarative navigation, use Link for plain URLs or RouteLink for type-safe route navigation:

tsx
<Link href="/about">About</Link>

<RouteLink
  route="/commander/[commander]"
  params={{commander: 'Kinnan, Bonder Prodigy'}}
>
  Kinnan, Bonder Prodigy
</RouteLink>

URL-Driven State

A common Pastoria pattern is to store filter and sort state in the URL. Changes use replaceRoute to update params, which re-triggers getPreloadProps and loads new data without a full page reload:

tsx
function SortSelect({current}: {current: string}) {
  const {replaceRoute} = useNavigation();

  return (
    <select
      value={current}
      onChange={(e) => replaceRoute('/', {sortBy: e.target.value})}
    >
      <option value="POPULARITY">Popularity</option>
      <option value="WIN_RATE">Win Rate</option>
    </select>
  );
}

Styling

Pastoria supports both StyleX and Tailwind CSS for styling.

StyleX

Coming soon.

Tailwind

Add @tailwindcss/vite as a Vite plugin in your app's vite.config.ts:

ts
import tailwindcss from '@tailwindcss/vite';
import {pastoria} from '@pastoria/pastoria';

export default {
  plugins: [tailwindcss(), pastoria()],
};

Then import Tailwind in your globals.css and load it from app.tsx:

css
@import 'tailwindcss';
tsx
import './globals.css';

export default function App({children}: PropsWithChildren) {
  return <>{children}</>;
}

API Routes

Files named route.ts under pastoria/ become Express API routes. The file path maps to the URL, just like pages. Each file must default export an Express router:

ts
import express from 'express';
import {toNodeHandler} from 'better-auth/node';
import {auth} from '#src/lib/server/auth.js';

const router = express.Router();

router.all('/*splat', (req, res, next) => {
  toNodeHandler(auth)(req, res).catch(next);
});

export default router;

API routes support dynamic segments the same way pages do:

FileRoute
pastoria/api/auth/route.ts/api/auth
pastoria/api/greet/[name]/route.ts/api/greet/:name

API routes run server-side only and have full access to Node.js APIs. They are mounted alongside the page handler, so avoid defining API routes at paths that overlap with page routes.

TIP

Pastoria includes a built-in GraphQL API endpoint at /api/graphql. API routes are for everything else: authentication callbacks, webhooks, redirects, or integrating third-party services.

Configuration

Pastoria Environment

The environment file configured pastoria/environment.ts configures the server-side environment and runtime of the pastoria app. It must default export a PastoriaEnvironment object:

ts
import {PastoriaEnvironment} from '@pastoria/runtime/server';
import {createContext} from '#lib/server/my-context';
import {schema} from '#lib/server/my-schema';

export default new PastoriaEnvironment({
  schema,
  createContext: () => createContext(),
});

The PastoriaEnvironment constructor has the following options:

  • schema: The GraphQL schema for the application.This schema will be used for both the GraphQL API endpointand the Relay server environment during SSR.
  • createContext: Factory function to create a context for each request.
  • enableGraphiQLInProduction: Enable GraphiQL interface in production. By default, GraphiQL is only available in development mode. Set to true to enable it in production as well.
  • persistedQueriesOnlyInProduction: Only allow persisted queries to be executed in production. When true, plain text GraphQL queries will be rejected in production. In development mode, plain text queries are always allowed (for GraphiQL).

Root App

Pastoria uses the component defined in pastoria/app.tsx as a common parent to all pages. Use it to:

  • Set up shared React providers
  • Perform one-time client initialization
  • Add global CSS
ts
import type {PropsWithChildren} from 'react';

import './globals.css';

export default function AppRoot({children}: PropsWithChildren) {
  return (
    <>
      <title>Pastoria Starter</title>
      {children}
    </>
  );
}

This component must be the default export, and must render its children.

Deployment

Pastoria apps are built into a standalone Node.js server. Build and run your app with:

sh
$ pastoria build
$ NODE_ENV=production pastoria-server

The build outputs two directories:

  • dist/client/ — static assets (JS, CSS, images) with content hashes for long-term caching
  • dist/server/ — the compiled server entry

The production server (pastoria-server from @pastoria/server) serves static files from dist/client/ and handles SSR and API routes from the server bundle. It listens on port 8000 by default.

Docker

Pastoria apps work well in Docker. A typical Dockerfile:

dockerfile
FROM node:22-slim AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build

FROM node:22-slim
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
COPY --from=build /app/dist ./dist
COPY --from=build /app/public ./public
COPY --from=build /app/__generated__/router/persisted_queries.json \
  ./__generated__/router/persisted_queries.json
ENV NODE_ENV=production
EXPOSE 8000
CMD ["pnpm", "start"]

FAQ

Can I use Server components?

Not yet. Server components are often redundant and overstep Relay’s data management.

Can I use plain JavaScript?

No. Pastoria apps use TypeScript to ensure full end-to-end type safety.