Adding Keystatic to a Remix project


This guide assumes you have an existing Remix project.

If you don't have an existing Remix project, you can create a new one with the following command:

npx create-remix@latest

Installing dependencies

Add Keystatic's core and remix packages and @markdoc/markdoc:

npm install @keystatic/core @keystatic/remix @markdoc/markdoc

Creating a Keystatic config file

Create a file called keystatic.config.ts in the root of the project and add the following code to define both your storage type (local) and a single content collection (posts):

// keystatic.config.ts
import { config, fields, collection } from '@keystatic/core';

export default config({
  storage: {
    kind: 'local',
  collections: {
    posts: collection({
      label: 'Posts',
      slugField: 'title',
      path: 'app/content/posts/*',
      format: { contentField: 'content' },
      schema: {
        title: fields.slug({ name: { label: 'Title' } }),
        content: fields.markdoc({ label: 'Content' }),

Keystatic is now configured to manage your content based on your schema.

Setting up the Keystatic Admin UI

Create an app/routes/keystatic.$.tsx file that will host all Admin UI routes:

// app/routes/keystatic.$.tsx
import { makePage } from '@keystatic/remix/ui';
import config from '../../keystatic.config';

export default makePage(config);

Next, create an app/routes/api.keystatic.$.tsx file that will handle API routes for Keystatic’s Admin UI:

// app/routes/api.keystatic.$.tsx
import type { ActionFunction, LoaderFunction } from '@remix-run/node';
import { handleLoader } from '@keystatic/remix/api';
import config from '../../keystatic.config';

export const loader: LoaderFunction = args => handleLoader({ config }, args);
export const action: ActionFunction = args => handleLoader({ config }, args);

Configuring server dependencies

Because Keystatic only provides an ESM build, server dependencies need to be configured.

Traditional Remix

If you're using the traditional (non-Vite) version of Remix, you can configure server dependencies in your remix.config.js file:

/** @type {import('@remix-run/dev').AppConfig} */
export default {
  ignoredRouteFiles: ['**/.*'],
+  serverDependenciesToBundle: [/^@keystatic\//, 'minimatch'],

Remix + Vite

If you're using Remix + Vite, you need to configure the ssr.noExternal option in the Vite config instead:

// vite.config.js
export default defineConfig({
  plugins: [remix()],
+  ssr: {
+    noExternal: [/^@keystatic\//, 'minimatch'],
+  },

Running Keystatic

You can now launch the Keystatic Admin UI. Start the Remix dev server:

npm run dev

Visit the /keystatic using localhost or in your browser to see the Keystatic Admin UI running.

Creating a new post


In our Keystatic config file, we've set the path property for our posts collection to app/content/posts/*.

As a result, creating a new post from the Keystatic Admin UI should create a new content directory in the app directory, with the new post .mdoc file inside!

Go ahead — create a new post from the Admin UI, and hit save.

You will find your new post inside the app/content/posts directory:

└── content
    └── posts
        └── my-first-post.mdoc

Navigate to that file in your code editor and verify that you can see the Markdown content you entered. For example:

title: My First Post

This is my very first post. I am **super** excited.

Rendering Keystatic content


Keystatic provides a Reader API to bring content to the front end. As it is a Node API it must be run server-side.

Displaying a collection list

The following example displays a list of each post title, with a link to an individual post page:

// app/routes/posts._index.tsx
import { createReader } from '@keystatic/core/reader'
import { Link, useLoaderData } from '@remix-run/react'
import { json } from '@remix-run/node'

import keystaticConfig from '../../keystatic.config'

export async function loader() {
  // 1. Create a reader
  const reader = createReader(process.cwd(), keystaticConfig)
  // 2. Read the "Posts" collection
  const posts = await reader.collections.posts.all()
  return json({ posts })

export default function Page() {
  const { posts } = useLoaderData<typeof loader>()

  return (
      { => (
        <li key={post.slug}>
          <Link to={`/posts/${post.slug}`}>{post.entry.title}</Link>

Displaying a single collection entry

To display content from an individual post, you can use Markdoc's transform and renderers.react functions:

// app/routes/posts.$slug.tsx
import React from 'react';
import Markdoc from '@markdoc/markdoc'
import { createReader } from '@keystatic/core/reader'
import { type LoaderFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'

import keystaticConfig from '../../keystatic.config'

export async function loader({ params }: LoaderFunctionArgs) {
  const reader = createReader(process.cwd(), keystaticConfig)
  const slug = params.slug
  if (!slug) throw json('Not Found', { status: 404 })
  const post = await
    // Retrieve the content data directly, instead
    // of getting an async `content()` function
    { resolveLinkedFiles: true }
  if (!post) throw json('Not Found', { status: 404 })
    const errors = Markdoc.validate(post.content.node, markdocConfig);
  if (errors.length) {
    throw new Error('Invalid content');
  const content = Markdoc.transform(post.content.node, markdocConfig);
  return json({
    post: {
      title: post.title,

export default function Post() {
  const { post } = useLoaderData<typeof loader>()
  return (
      {Markdoc.renderers.react(post.content, React)}

Deploying Keystatic + Remix


Coming soon!

You will also probably want to connect Keystatic to GitHub so you can manage content on the deployed instance of the project.