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 127.0.0.1
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:
app
└── 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 (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link to={`/posts/${post.slug}`}>{post.entry.title}</Link>
</li>
))}
</ul>
)
}
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 reader.collections.posts.read(
slug,
// 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) {
console.error(errors);
throw new Error('Invalid content');
}
const content = Markdoc.transform(post.content.node, markdocConfig);
return json({
post: {
title: post.title,
content,
},
});
}
export default function Post() {
const { post } = useLoaderData<typeof loader>()
return (
<>
<h1>{post.title}</h1>
{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.