Request and Response objects, and a Patchsmith account.
Get your project key from Patchsmith and make it available to your server as PATCHSMITH_PROJECT_KEY. Keep it out of client bundles. The examples use @/ for your source directory; adjust imports to match your project.
Install Patchsmith
Choose a validation library to use with Patchsmith. If you’re unsure, use Zod. Then add Patchsmith and your chosen library to your project:
- Zod
- ArkType
- Valibot
npm install patchsmith@beta zod
pnpm add patchsmith@beta zod
yarn add patchsmith@beta zod
bun add patchsmith@beta zod
npm install patchsmith@beta arktype
pnpm add patchsmith@beta arktype
yarn add patchsmith@beta arktype
bun add patchsmith@beta arktype
npm install patchsmith@beta valibot @valibot/to-json-schema
pnpm add patchsmith@beta valibot @valibot/to-json-schema
yarn add patchsmith@beta valibot @valibot/to-json-schema
bun add patchsmith@beta valibot @valibot/to-json-schema
Define the shared contract
Create A slot’s optional
src/lib/patchsmith.ts to define the contract between installed apps and your platform. It declares components, views, slots, and scopes that identify an installation’s owner. Both your server and frontend import this file, so keep server credentials and logic elsewhere.Breaking contract changes, like removing a component or requiring a field, can
break existing apps.
- Zod
- ArkType
- Valibot
src/lib/patchsmith.ts
import { defineContract, type Component } from "patchsmith";
import { primitives } from "patchsmith/zod";
import { z } from "zod";
// Define the components you want apps to use
const Button = {
fields: z.object({
variant: z.enum(["default", "secondary"]),
}),
callbacks: {
onClick: {},
},
children: true,
} satisfies Component;
const Input = {
fields: z.object({ value: z.string() }),
callbacks: {
onValueChange: {
input: z.object({ value: z.string() }),
},
},
} satisfies Component;
export const contract = defineContract({
// Values form the installation key, e.g. userId + teamId scopes installs per user per team.
scopes: { user: z.object({ userId: z.string().min(1) }) },
components: {
// These give apps basic layout and styling components; we strongly recommend including them.
...primitives({
// Customise these colors to match your app's design tokens.
tokens: {
borderColor: {
default: "var(--border)",
input: "var(--input)",
destructive: "var(--destructive)",
},
textColor: {
default: "var(--foreground)",
muted: "var(--muted-foreground)",
primary: "var(--primary)",
destructive: "var(--destructive)",
},
},
}),
Button,
Input,
},
// Views share page data with apps, which can provide UI for each view.
// Patchsmith shows the closest match, falling back to "/" if the app provides it.
views: {
"/": z.object({
user: z.object({ id: z.string(), name: z.string() }),
}),
},
// Slots define where apps render and which views they support.
slots: {
page: {
views: ["/"],
description:
"Full screen content accessed via the sidebar. Width and height vary by device; use responsive layouts.",
},
},
});
src/lib/patchsmith.ts
import { defineContract, type Component } from "patchsmith";
import { primitives } from "patchsmith/arktype";
import { type } from "arktype";
// Define the components you want apps to use
const Button = {
fields: type({
variant: "'default' | 'secondary'",
}),
callbacks: {
onClick: {},
},
children: true,
} satisfies Component;
const Input = {
fields: type({ value: "string" }),
callbacks: {
onValueChange: {
input: type({ value: "string" }),
},
},
} satisfies Component;
export const contract = defineContract({
// Values form the installation key, e.g. userId + teamId scopes installs per user per team.
scopes: {
user: type({ userId: "string > 0" }),
},
components: {
// These give apps basic layout and styling components; we strongly recommend including them.
...primitives({
// Customise these colors to match your app's design tokens.
tokens: {
borderColor: {
default: "var(--border)",
input: "var(--input)",
destructive: "var(--destructive)",
},
textColor: {
default: "var(--foreground)",
muted: "var(--muted-foreground)",
primary: "var(--primary)",
destructive: "var(--destructive)",
},
},
}),
Button,
Input,
},
// Views share page data with apps, which can provide UI for each view.
// Patchsmith shows the closest match, falling back to "/" if the app provides it.
views: {
"/": type({
user: { id: "string", name: "string" },
}),
},
// Slots define where apps render and which views they support.
slots: {
page: {
views: ["/"],
description:
"Full screen content accessed via the sidebar. Width and height vary by device; use responsive layouts.",
},
},
});
src/lib/patchsmith.ts
import { defineContract, type Component } from "patchsmith";
import { primitives } from "patchsmith/valibot";
import * as v from "valibot";
// Define the components you want apps to use
const Button = {
fields: v.object({
variant: v.picklist(["default", "secondary"]),
}),
callbacks: {
onClick: {},
},
children: true,
} satisfies Component;
const Input = {
fields: v.object({ value: v.string() }),
callbacks: {
onValueChange: {
input: v.object({ value: v.string() }),
},
},
} satisfies Component;
export const contract = defineContract({
// Values form the installation key, e.g. userId + teamId scopes installs per user per team.
scopes: { user: v.object({ userId: v.pipe(v.string(), v.minLength(1)) }) },
components: {
// These give apps basic layout and styling components; we strongly recommend including them.
...primitives({
// Customise these colors to match your app's design tokens.
tokens: {
borderColor: {
default: "var(--border)",
input: "var(--input)",
destructive: "var(--destructive)",
},
textColor: {
default: "var(--foreground)",
muted: "var(--muted-foreground)",
primary: "var(--primary)",
destructive: "var(--destructive)",
},
},
}),
Button,
Input,
},
// Views are URL-like paths for your product's context; apps can provide UI for each.
// Patchsmith shows the closest match, checking parent paths up to "/".
views: {
"/": v.object({
user: v.object({ id: v.string(), name: v.string() }),
}),
},
// Slots define where apps render and which views they support.
slots: {
page: {
views: ["/"],
description:
"Full screen content accessed via the sidebar. Width and height vary by device; use responsive layouts.",
},
},
});
description gives app authors and agents layout and sizing guidance. Your host controls the actual dimensions. See Slots for a side panel example.Implement the server
Create
src/lib/patchsmith-server.ts to connect your contract to Patchsmith using your project key. Set baseUrl to your host’s Patchsmith handler URL in each environment, including production.- Zod
- ArkType
- Valibot
src/lib/patchsmith-server.ts
import { createServer } from "patchsmith/server";
import { contract } from "./patchsmith";
export const patchSmith = createServer({
contract,
projectKey: process.env.PATCHSMITH_PROJECT_KEY,
baseUrl:
process.env.NODE_ENV === "development"
? "http://localhost:3000/api/patchsmith"
: "https://your-app.com/api/patchsmith",
});
src/lib/patchsmith-server.ts
import { createServer } from "patchsmith/server";
import { contract } from "./patchsmith";
export const patchSmith = createServer({
contract,
projectKey: process.env.PATCHSMITH_PROJECT_KEY,
baseUrl:
process.env.NODE_ENV === "development"
? "http://localhost:3000/api/patchsmith"
: "https://your-app.com/api/patchsmith",
});
src/lib/patchsmith-server.ts
import { createServer } from "patchsmith/server";
import { toJsonSchema } from "@valibot/to-json-schema";
import { contract } from "./patchsmith";
export const patchSmith = createServer({
contract,
projectKey: process.env.PATCHSMITH_PROJECT_KEY,
baseUrl:
process.env.NODE_ENV === "development"
? "http://localhost:3000/api/patchsmith"
: "https://your-app.com/api/patchsmith",
schemaSerializer: toJsonSchema,
});
Create the client
Create
src/lib/patchsmith-client.tsx to connect your frontend to the Patchsmith API. Map each contract component to its React implementation. The example assumes your Button and Input components accept the props shown below.src/lib/patchsmith-client.tsx
"use client";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { contract } from "@/lib/patchsmith";
import { createClient, primitives } from "patchsmith/react";
export const patchSmithClient = createClient({
contract,
// Replace this with your host origin and handler path.
baseUrl:
process.env.NODE_ENV === "development"
? "http://localhost:3000/api/patchsmith"
: "https://your-app.com/api/patchsmith",
components: {
...primitives,
Button: ({ props, children }) => (
<Button variant={props.variant} onClick={props.onClick}>
{children}
</Button>
),
Input: ({ props }) => (
<Input
value={props.value}
onChange={(event) => props.onValueChange({ value: event.target.value })}
/>
),
},
});
export const {
Provider: PatchsmithProvider,
RenderSlot,
useApps,
useViewContext,
useViews,
} = patchSmithClient;
export default patchSmithClient;
Add the API handler
Mount the handler at your Patchsmith base path. If you use a path other than
/api/patchsmith, update baseUrl in both patchsmith-server.ts and patchsmith-client.tsx to match.Choose your framework below. Replace auth with your authentication implementation and return a stable subjectId and verified scope values from the session.- Next.js App Router
- TanStack Start
- React Router
- Nitro
- Hono
- Other
app/api/patchsmith/[...patchsmith]/route.ts
import { patchSmith } from "@/lib/patchsmith-server";
async function handleRequest(request: Request) {
return patchSmith.handler(request, {
authenticate: async () => {
// Replace this with your own authentication logic.
const session = await auth(request);
if (!session) {
return null;
}
return {
subjectId: session.user.id,
scopes: { user: { userId: session.user.id } },
};
},
});
}
export const GET = handleRequest;
export const POST = handleRequest;
routes/api/patchsmith.$.ts
import { patchSmith } from "@/lib/patchsmith-server";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/api/patchsmith/$")({
server: {
handlers: {
GET: handleRequest,
POST: handleRequest,
},
},
});
async function handleRequest({ request }: { request: Request }) {
return patchSmith.handler(request, {
authenticate: async () => {
// Replace this with your own authentication logic.
const session = await auth(request);
if (!session) {
return null;
}
return {
subjectId: session.user.id,
scopes: { user: { userId: session.user.id } },
};
},
});
}
routes/api.patchsmith.$.ts
import { patchSmith } from "@/lib/patchsmith-server";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
async function handleRequest(request: Request) {
return patchSmith.handler(request, {
authenticate: async () => {
// Replace this with your own authentication logic.
const session = await auth(request);
if (!session) {
return null;
}
return {
subjectId: session.user.id,
scopes: { user: { userId: session.user.id } },
};
},
});
}
export async function loader({ request }: LoaderFunctionArgs) {
return handleRequest(request);
}
export async function action({ request }: ActionFunctionArgs) {
return handleRequest(request);
}
server/api/patchsmith/[...].ts
import { patchSmith } from "@/lib/patchsmith-server";
import { toWebRequest } from "h3";
export default defineEventHandler(async (event) => {
const request = toWebRequest(event);
return patchSmith.handler(request, {
authenticate: async () => {
// Replace this with your own authentication logic.
const session = await auth(request);
if (!session) {
return null;
}
return {
subjectId: session.user.id,
scopes: { user: { userId: session.user.id } },
};
},
});
});
src/index.ts
import { Hono } from "hono";
import { patchSmith } from "@/lib/patchsmith-server";
const app = new Hono();
app.on(["GET", "POST"], "/api/patchsmith/*", async (c) => {
return patchSmith.handler(c.req.raw, {
authenticate: async () => {
// Replace this with your own authentication logic.
const session = await auth(c.req.raw);
if (!session) {
return null;
}
return {
subjectId: session.user.id,
scopes: { user: { userId: session.user.id } },
};
},
});
});
export default app;
Pass your framework’s
Request to this handler and return its Response.src/lib/patchsmith-handler.ts
import { patchSmith } from "@/lib/patchsmith-server";
export async function handleRequest(request: Request): Promise<Response> {
return patchSmith.handler(request, {
authenticate: async () => {
// Replace this with your own authentication logic.
const session = await auth(request);
if (!session) {
return null;
}
return {
subjectId: session.user.id,
scopes: { user: { userId: session.user.id } },
};
},
});
}
Add the provider
Wrap pages that use Patchsmith hooks or render apps in
PatchsmithProvider. Choose the root layout for your framework:- Next.js App Router
- TanStack Start
- React Router
app/layout.tsx
import type { ReactNode } from "react";
import { PatchsmithProvider } from "@/lib/patchsmith-client";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<PatchsmithProvider>{children}</PatchsmithProvider>
</body>
</html>
);
}
src/routes/__root.tsx
import type { ReactNode } from "react";
import {
createRootRoute,
HeadContent,
Outlet,
Scripts,
} from "@tanstack/react-router";
import { PatchsmithProvider } from "@/lib/patchsmith-client";
export const Route = createRootRoute({
shellComponent: RootDocument,
component: Outlet,
});
function RootDocument({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<PatchsmithProvider>{children}</PatchsmithProvider>
<Scripts />
</body>
</html>
);
}
app/root.tsx
import type { ReactNode } from "react";
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
import { PatchsmithProvider } from "@/lib/patchsmith-client";
export function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return (
<PatchsmithProvider>
<Outlet />
</PatchsmithProvider>
);
}
Render a view
Use
useViewContext to share page data with apps and useViews to find apps available in the page slot. Render the selected app with RenderSlot.- Next.js App Router
- TanStack Start
- React Router
app/app-views/page.tsx
"use client";
import { useState } from "react";
import { RenderSlot, useViews, useViewContext } from "@/lib/patchsmith-client";
export default function Page() {
const [selectedId, setSelectedId] = useState("");
const { data: views, isPending, error } = useViews({ slot: "page" });
// Replace this with your own authentication hook.
const { data: session, isPending: isSessionPending } = useSession();
useViewContext(
"/",
session
? { context: { user: session.user } }
: { context: undefined, loading: true }
);
const selectedView =
views?.find((view) => view.app.id === selectedId) ?? views?.[0];
if (isPending || isSessionPending) return <p>Loading views…</p>;
if (error) return "An error has occurred: " + error.message;
if (!selectedView) return <p>No views available yet.</p>;
return (
<>
<label>
View
<select
value={selectedView.app.id}
onChange={(event) => setSelectedId(event.target.value)}
>
{views?.map((view) => (
<option key={view.app.id} value={view.app.id}>
{view.app.name ?? view.app.id}
</option>
))}
</select>
</label>
<RenderSlot
key={selectedView.app.id}
app={selectedView.app}
slot="page"
/>
</>
);
}
src/routes/app-views.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import { RenderSlot, useViews, useViewContext } from "@/lib/patchsmith-client";
export const Route = createFileRoute("/app-views")({ component: Page });
function Page() {
const [selectedId, setSelectedId] = useState("");
const { data: views, isPending, error } = useViews({ slot: "page" });
// Replace this with your own authentication hook.
const { data: session, isPending: isSessionPending } = useSession();
useViewContext(
"/",
session
? { context: { user: session.user } }
: { context: undefined, loading: true }
);
const selectedView =
views?.find((view) => view.app.id === selectedId) ?? views?.[0];
if (isPending || isSessionPending) return <p>Loading views…</p>;
if (error) return "An error has occurred: " + error.message;
if (!selectedView) return <p>No views available yet.</p>;
return (
<>
<label>
View
<select
value={selectedView.app.id}
onChange={(event) => setSelectedId(event.target.value)}
>
{views?.map((view) => (
<option key={view.app.id} value={view.app.id}>
{view.app.name ?? view.app.id}
</option>
))}
</select>
</label>
<RenderSlot
key={selectedView.app.id}
app={selectedView.app}
slot="page"
/>
</>
);
}
app/routes/app-views.tsx
import { useState } from "react";
import { RenderSlot, useViews, useViewContext } from "@/lib/patchsmith-client";
export default function Page() {
const [selectedId, setSelectedId] = useState("");
const { data: views, isPending, error } = useViews({ slot: "page" });
// Replace this with your own authentication hook.
const { data: session, isPending: isSessionPending } = useSession();
useViewContext(
"/",
session
? { context: { user: session.user } }
: { context: undefined, loading: true }
);
const selectedView =
views?.find((view) => view.app.id === selectedId) ?? views?.[0];
if (isPending || isSessionPending) return <p>Loading views…</p>;
if (error) return "An error has occurred: " + error.message;
if (!selectedView) return <p>No views available yet.</p>;
return (
<>
<label>
View
<select
value={selectedView.app.id}
onChange={(event) => setSelectedId(event.target.value)}
>
{views?.map((view) => (
<option key={view.app.id} value={view.app.id}>
{view.app.name ?? view.app.id}
</option>
))}
</select>
</label>
<RenderSlot
key={selectedView.app.id}
app={selectedView.app}
slot="page"
/>
</>
);
}
Verify the integration
Start your host app, then check these endpoints at your configured host origin:The schema and metadata endpoints describe your public app contract. The apps endpoint requires a valid host session and returns the apps installed for the authenticated scopes. Each scope is named by the schema key, such as
GET /api/patchsmith/schema
GET /api/patchsmith/meta
GET /api/patchsmith/apps
user.While signed in to your host, open /app-views. You should see either an app view or “No views available yet.” An unauthenticated request to /api/patchsmith/apps should be rejected.If the routes return 404, check the catch-all route and handler path. If the schema works but apps fail to load, check that authenticate returns a valid session and that the client points to the same origin as your host.