
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.
$ vp create @pastoria@latestThis 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:
$ vp dlx skills add rrdelaney/pastoriaGenerating 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:
$ pastoriaThis 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
$ git diff --exit-code --quietPage 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:
| File | Route |
|---|---|
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.
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:
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:
$ relay-compiler && pastoriaEach 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:
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:
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:
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:
{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:
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:
<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:
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
}Navigation
Pastoria generates type-safe navigation utilities from your route definitions. Import them from the generated router:
import {useNavigation, Link, RouteLink} from '#genfiles/router/router';Programmatic Navigation
Use the useNavigation hook for imperative navigation:
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.
Link Components
For declarative navigation, use Link for plain URLs or RouteLink for type-safe route navigation:
<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:
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:
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:
@import 'tailwindcss';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:
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:
| File | Route |
|---|---|
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:
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. Whentrue, 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
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:
$ pastoria build
$ NODE_ENV=production pastoria-serverThe build outputs two directories:
dist/client/— static assets (JS, CSS, images) with content hashes for long-term cachingdist/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:
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.