Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add react-router v7 example #752

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ Available recipes include:

- [**next**](https://github.com/measuredco/puck/tree/main/recipes/next): Next.js 13 app example, using App Router and static page generation
- [**remix**](https://github.com/measuredco/puck/tree/main/recipes/remix): Remix Run v2 app example, using dynamic routes at root-level
- [**react-router**](https://github.com/measuredco/puck/tree/main/recipes/react-router): React Router v7 app example, using dynamic routes to create pages at any level

## Community

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import packageJson from "./package.json" assert { type: "json" };
import packageJson from "./package.json" with { type: "json" };
import nextra from "nextra";

const withNextra = nextra({
Expand Down
36 changes: 36 additions & 0 deletions packages/create-puck-app/templates/react-router/package.json.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "{{appName}}",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "cross-env NODE_ENV=production react-router build",
"dev": "react-router dev",
"start": "cross-env NODE_ENV=production react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@measured/puck": "{{puckVersion}}",
"@react-router/node": "^7.0.2",
"@react-router/serve": "^7.0.2",
"isbot": "^5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.0.2"
},
"devDependencies": {
"@react-router/dev": "^7.0.2",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"postcss": "^8.4.49",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vite-tsconfig-paths": "^5.1.4"
}
"engines": {
"node": ">=22.0.0"
}
}
27 changes: 27 additions & 0 deletions packages/create-puck-app/templates/react-router/tsconfig.json.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"include": [
"**/*",
"**/.server/**/*",
"**/.client/**/*",
".react-router/types/**/*"
],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"baseUrl": ".",
"paths": {
"~/*": ["./app/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}
6 changes: 6 additions & 0 deletions recipes/react-router/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.DS_Store
/node_modules/

# React Router
/.react-router/
/build/
39 changes: 39 additions & 0 deletions recipes/react-router/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# `react-router` recipe

The `react-router` recipe showcases one of the most powerful ways to implement Puck using to provide an authoring tool for any route in your React Router app.
matthewlynch marked this conversation as resolved.
Show resolved Hide resolved

## Demonstrates

- React Router v7 (framework) implementation
- JSON database implementation
- Splat route to use puck for any route on the platform

## Usage

Run the generator and enter `react-router` when prompted

```
npx create-puck-app my-app
```

Start the server

```
yarn dev
```

Navigate to the homepage at http://localhost:5173/. To edit the homepage, access the Puck editor at http://localhost:5173/edit.

You can do this for any **base** route on the application, **even if the page doesn't exist**. For example, visit http://localhost:5173/hello-world and you'll receive a 404. You can author and publish a page by visiting http://localhost:5173/hello-world/edit. After publishing, go back to the original URL to see your page.

## Using this recipe

To adopt this recipe you will need to:

- **IMPORTANT** Add authentication to `/edit` routes. This can be done by modifying the [route module action](https://reactrouter.com/start/framework/route-module#action) in the splat route `/app/routes/puck-splat.tsx`. **If you don't do this, Puck will be completely public.**
- Integrate your database into the functions in `/lib/pages.server.ts`
- Implement a custom puck configuration in `/app/puck.config.tsx`

## License

MIT © [Measured Co.](https://github.com/measuredco)
4 changes: 4 additions & 0 deletions recipes/react-router/app/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
html,
body {
margin: 0;
}
8 changes: 8 additions & 0 deletions recipes/react-router/app/components/puck-render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Data } from "@measured/puck";
import { Render } from "@measured/puck";

import { config } from "../../puck.config";

export function PuckRender({ data }: { data: Data }) {
return <Render config={config} data={data} />;
}
29 changes: 29 additions & 0 deletions recipes/react-router/app/lib/pages.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs/promises";
import type { Data } from "@measured/puck";

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const databasePath = path.join(__dirname, "..", "..", "database.json");

export async function getPage(path: string) {
const pages = await readDatabase();
return pages[path];
}

export async function savePage(path: string, data: Data) {
const pages = await readDatabase();
pages[path] = data;
await fs.writeFile(databasePath, JSON.stringify(pages), { encoding: "utf8" });
}

async function readDatabase() {
try {
const file = await fs.readFile(databasePath, "utf8");
return JSON.parse(file) as Record<string, Data>;
} catch (error: unknown) {
console.error(error);
return {};
}
}
18 changes: 18 additions & 0 deletions recipes/react-router/app/lib/resolve-puck-path.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export function resolvePuckPath(
path: string = "",
// `base` can be any valid origin, it is required for the URL constructor so
// we can return a pathname - you can change this if you want
base = "https://placeholder.com/"
) {
const url = new URL(path, base);
const segments = url.pathname.split("/");
const isEditorRoute = segments.at(-1) === "edit";
const pathname = isEditorRoute
? segments.slice(0, -1).join("/")
: url.pathname;

return {
isEditorRoute,
path: new URL(pathname, base).pathname,
};
}
72 changes: 72 additions & 0 deletions recipes/react-router/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";

import type { Route } from "./+types/root";
import puckStyles from "@measured/puck/puck.css?url";
import stylesheet from "./app.css?url";

export const links: Route.LinksFunction = () => [
{ rel: "stylesheet", href: puckStyles },
{ rel: "stylesheet", href: stylesheet },
];

export function Layout({ children }: { children: React.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 <Outlet />;
}

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;

if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (
import.meta.env.NODE_ENV !== "production" &&
error &&
error instanceof Error
) {
details = error.message;
stack = error.stack;
}

return (
<main>
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre>
<code>{stack}</code>
</pre>
)}
</main>
);
}
7 changes: 7 additions & 0 deletions recipes/react-router/app/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { RouteConfig } from "@react-router/dev/routes";
import { route, index } from "@react-router/dev/routes";

export default [
index("routes/_index.tsx"),
route("*", "routes/puck-splat.tsx"),
] satisfies RouteConfig;
31 changes: 31 additions & 0 deletions recipes/react-router/app/routes/_index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Route } from "./+types/_index";
import { PuckRender } from "~/components/puck-render";
import { resolvePuckPath } from "~/lib/resolve-puck-path.server";
import { getPage } from "~/lib/pages.server";

export async function loader() {
const { isEditorRoute, path } = resolvePuckPath("/");
let page = await getPage(path);

if (!page) {
throw new Response("Not Found", { status: 404 });
}

return {
isEditorRoute,
path,
data: page,
};
}

export function meta({ data: loaderData }: Route.MetaArgs) {
return [
{
title: loaderData.data.root.title,
},
];
}

export default function PuckSplatRoute({ loaderData }: Route.ComponentProps) {
return <PuckRender data={loaderData.data} />;
}
91 changes: 91 additions & 0 deletions recipes/react-router/app/routes/puck-splat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useFetcher, useLoaderData } from "react-router";
import type { Data } from "@measured/puck";
import { Puck, Render } from "@measured/puck";

import type { Route } from "./+types/puck-splat";
chrisvxd marked this conversation as resolved.
Show resolved Hide resolved
import { config } from "../../puck.config";
import { resolvePuckPath } from "~/lib/resolve-puck-path.server";
import { getPage, savePage } from "~/lib/pages.server";

export async function loader({ params }: Route.LoaderArgs) {
const pathname = params["*"] ?? "/";
const { isEditorRoute, path } = resolvePuckPath(pathname);
let page = await getPage(path);

// Throw a 404 if we're not rendering the editor and data for the page does not exist
if (!isEditorRoute && !page) {
throw new Response("Not Found", { status: 404 });
}

// Empty shell for new pages
if (isEditorRoute && !page) {
page = {
content: [],
root: {
props: {
title: "",
},
},
};
}

return {
isEditorRoute,
path,
data: page,
};
}

export function meta({ data: loaderData }: Route.MetaArgs) {
return [
{
title: loaderData.isEditorRoute
? `Edit: ${loaderData.path}`
: loaderData.data.root.title,
},
];
}

export async function action({ params, request }: Route.ActionArgs) {
const pathname = params["*"] ?? "/";
const { path } = resolvePuckPath(pathname);
const body = (await request.json()) as { data: Data };

await savePage(path, body.data);
}

function Editor() {
const loaderData = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();

return (
<Puck
config={config}
data={loaderData.data}
onPublish={async (data) => {
await fetcher.submit(
{
data,
},
{
action: "",
method: "post",
encType: "application/json",
}
);
}}
/>
);
}

export default function PuckSplatRoute({ loaderData }: Route.ComponentProps) {
return (
<div>
{loaderData.isEditorRoute ? (
<Editor />
) : (
<Render config={config} data={loaderData.data} />
)}
</div>
);
}
1 change: 1 addition & 0 deletions recipes/react-router/database.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"/":{"content":[{"type":"HeadingBlock","props":{"title":"Edit this page by adding /edit to the end of the URL","id":"HeadingBlock-1694032984497"}}],"root":{"props":{"title":"Puck + React Router 7 demo"}},"zones":{}}}
Loading
Loading