Skip to content

React Router Tutorial

This guide will walk you through setting up Composify in your React Router project. We'll assume you already have a React Router project up and running. If not, start with the React Router quick start guide first.

First, make sure the mock API from our prerequisites guide is running on http://localhost:9000. We'll use it to read and write our page content.

Install Composify

Add Composify to your project with your preferred package manager:

npm
npm install @composify/react --save

Register your components

You can use plain HTML elements, but Composify really shines when you use your own components. Let's create a few simple ones.

Heading
/* app/components/Heading.tsx */
import { createElement, type FC, type PropsWithChildren } from 'react';
 
type Props = PropsWithChildren<{
  level?: 1 | 2 | 3;
  weight?: 'semibold' | 'bold' | 'extrabold';
}>;
 
const TEXT_SIZE = {
  1: 'text-4xl',
  2: 'text-3xl',
  3: 'text-2xl',
};
 
const FONT_WEIGHT = {
  semibold: 'font-semibold',
  bold: 'font-bold',
  extrabold: 'font-extrabold',
};
 
export const Heading: FC<Props> = ({
  level = 1,
  weight = 'extrabold',
  children,
}) =>
  createElement(
    `h${level}`,
    { className: `text-neutral-900 ${FONT_WEIGHT[weight]} ${TEXT_SIZE[level]}` },
    children
  );

Now, let's register them in the Catalog:

Heading
/* app/components/Heading.tsx */
import { Catalog } from '@composify/react/renderer';
 
/* ... */
 
Catalog.register('Heading', {
  component: Heading,
  props: {
    level: {
      label: 'Heading Level',
      type: 'radio',
      options: [
        {
          label: '1',
          value: 1,
        },
        {
          label: '2',
          value: 2,
        },
        {
          label: '3',
          value: 3,
        },
      ],
      default: 1,
    },
    weight: {
      label: 'Font Weight',
      type: 'select',
      options: [
        {
          label: 'Semibold',
          value: 'semibold',
        },
        {
          label: 'Bold',
          value: 'bold',
        },
        {
          label: 'Extrabold',
          value: 'extrabold',
        },
      ],
      default: 'extrabold',
    },
    children: {
      label: 'Content',
      type: 'text',
      default: 'Server Driven UI made easy',
    },
  },
});

Finally, create a central export file at app/components/index.ts so we can import them all with a single line:

app/components/index.ts
export { Heading } from './Heading';
export { Body } from './Body';
export { Button } from './Button';
export { HStack } from './HStack';
export { VStack } from './VStack';

Render a page

With our components registered, let's render a page. The Renderer takes the saved JSX and renders it using your components.

app/routes/page.tsx
import '~/components';
 
import { Renderer } from '@composify/react/renderer';
import { type LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
 
export async function loader({ params }: LoaderFunctionArgs) {
  const slug = params.slug ?? '';
  const res = await fetch(`http://localhost:9000/documents/${slug}`);
  const { content } = await res.json().catch(() => ({}));
 
  if (!content) {
    throw new Response('', { status: 404 });
  }
 
  return { slug, content };
}
 
export default function Page() {
  const { slug, content } = useLoaderData<typeof loader>();
 
  return (
    <Renderer source={content} />
  );
}

Also register the route in app/routes.ts:

app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
 
export default [
  index('routes/home.tsx'),
  route(':slug', 'routes/page.tsx'),
] satisfies RouteConfig;

Now you can test it:

Set up the Editor

Now for the fun part: setting up the visual editor. To create or update content, we'll use the Editor component.

app/routes/editor.tsx
import '@composify/react/style.css';
import '~/components';
 
import { Editor } from '@composify/react/editor';
import { type LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
 
export async function loader({ params }: LoaderFunctionArgs) {
  const slug = params.slug ?? '';
  const res = await fetch(`http://localhost:9000/documents/${slug}`);
  const { content } = await res.json().catch(() => ({}));
 
  return {
    slug,
    content: content ?? '<VStack size={{ height: 100 }} backgroundColor="#f8fafc" />',
  };
}
 
export default function EditorPage() {
  const { slug, content } = useLoaderData<typeof loader>();
 
  return <Editor title={slug} source={content} />;
}

Don't forget to register the route in app/routes.ts:

app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
 
export default [
  index('routes/home.tsx'),
  route(':slug', 'routes/page.tsx'),
  route('editor/:slug', 'routes/editor.tsx'),
] satisfies RouteConfig;

A few key points:

  • @composify/react/style.css is required — it contains the core editor styles.
  • Import your components (~/components) so they're available in the editor.

Open http://localhost:3000/editor/foo/ and you should see the editor UI with the document loaded.

Handle saving

Right now, clicking Save does nothing. Let's wire it up to our API using the onSubmit handler.

app/routes/editor.tsx
import '@composify/react/style.css';
import '~/components';
 
import { Editor } from '@composify/react/editor';
import { type LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useNavigate } from 'react-router';
 
export async function loader({ params }: LoaderFunctionArgs) {
  const slug = params.slug ?? '';
  const res = await fetch(`http://localhost:9000/documents/${slug}`);
  const { content } = await res.json().catch(() => ({}));
 
  return {
    slug,
    content: content ?? '<VStack size={{ height: 100 }} backgroundColor="#f8fafc" />',
  };
}
 
export default function EditorPage() {
  const { slug, content } = useLoaderData<typeof loader>();
  const navigate = useNavigate();
 
  const handleSubmit = async (source: string) => {
    await fetch(`http://localhost:9000/documents/${slug}`, {
      method: 'DELETE',
    }).catch(() => null);
 
    await fetch('http://localhost:9000/documents', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        id: slug,
        content: source,
      }),
    });
 
    if (!window.confirm('Saved successfully. Keep editing?')) {
      navigate(`/${slug}`);
    }
  };
 
  return <Editor title={slug} source={content} onSubmit={handleSubmit} />;
}

Now, when you hit Save, the editor sends the updated JSX to your /documents API. If you select No in the confirmation dialog, you'll be redirected to the rendered page.

Try it out!

  1. Visit http://localhost:3000/foo/ to see the saved content.
  2. Open http://localhost:3000/editor/foo/, make a change, and click Save — the rendered page updates instantly.
  3. Visit http://localhost:3000/baz/ to see a 404.
  4. Open http://localhost:3000/editor/baz/, creat content, click Save, and the new page will be live immediately 🎉

Wrapping up

And that's it! You now have:

  • A document store (currently powered by json-server, but could be a real database later)
  • An editor where you can visually compose pages using your own components
  • A renderer that turns saved JSX back into real UI

Where to go from here?

  • Replace json-server with a real database
  • Add authentication and user permissions
  • Deploy it so your whole team can collaborate

For unlimited bandwidth, built-in version history, and collaboration features, try Composify Cloud — or self-host it, since it's all open source.