Tina Docs
Introduction
Core Concepts
Querying Content
Editing
Customizing Tina
Going To Production
Drafts
Guides
Further Reference

Converting an existing Gatsby project to TinaCMS


TinaCMS does not officially support Gatsby. We recommend migrating your Gatsby site to a well supported framework such as Next.JS instead.

Introduction

In this tutorial, we'll guide you through converting an existing Gatsby MDX blog to TinaCMS. We've provided a starter repo for you to follow along, which is a fork of the official Gatsby MD blog starter.

Limitations

There are a few limitations to the approach outlined in this guide.

  • Loss of Gatsby's image optimization
  • Gatsby uses GitHub Flavored Markdown, which TinaCMS does not fully support

Getting started

First, clone our sample Gatsby project. Then you'll want to navigate into the blog's directory.

git clone https://github.com/tinacms/gatsby-mdx-example-blog
cd gatsby-mdx-example-blog

Adding Tina

Awesome! You're set up and ready to start adding TinaCMS. You can initialize it using the command below.

npx @tinacms/cli@latest init

After running the command above you'll receive a few prompts

  1. When prompted to select a framework select other
  2. Choose yarn as your package manager
  3. When asked if you'd like to use Typescript choose yes
  4. Set the public assets location to public

Setting up Gatsby for Tina

Now that we've added Tina to our project, there are a few more steps to integrate it with Gatsby. Start by adding the following line at the top of tina/config.js

export default defineConfig({
+ client: { skip: true },
// ...

Next, we'll set up the URL for the visual editor using Express.

+ import express from "express";
//...
+ const onCreateDevServer: GatsbyNode["onCreateDevServer"] = ({ app }) => {
+ app.use("/admin", express.static("public/admin"));
+ };
//...
- export { createPages, createSchemaCustomization, onCreateNode }
+ export { createPages, createSchemaCustomization, onCreateNode, onCreateDevServer }

To make sure Tina runs when the app is in development mode, update the startup command in package.json as follows:

"scripts": {
"build": "gatsby build",
- "develop": "gatsby develop",
+ "develop": "npx tinacms dev -c \"gatsby develop\"",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"",
"start": "gatsby develop",
"serve": "gatsby serve",
"clean": "gatsby clean",
"test": "echo \"Write tests! -> https://gatsby.dev/unit-testing\" && exit 1"
}

To fix any bugs related to conflicting GraphQL versions inside of node modules we'll also force Gatsby to use the same version as TinaCMS in package.json.

Add the following:

{
//...
+ "resolutions": {
+ "graphql": "^15.8.0",
+ "**/graphql": "^15.8.0"
+ }
}

Configuring our Schema

Now we've added Tina to our project but it's not pointing at our existing markdown files. Let's fix that.

Open up tina/config.ts and change the path to point to our blog directory. We'll also need to update our schema since we'll work with mdx files.

Next we'll need to change the location where images get uploaded. Since public doesn't get tracked in Git.

By moving our images to static, we're ensuring that they'll be tracked in git and bundled at run time.
export default defineConfig({
branch,
client: { skip: true },
// Get this from tina.io
clientId: process.env.NEXT_PUBLIC_TINA_CLIENT_ID,
// Get this from tina.io
token: process.env.TINA_TOKEN,
build: {
outputFolder: "admin",
publicFolder: "public",
},
media: {
tina: {
- mediaRoot: "",
+ mediaRoot: "images"
- publicFolder: "public",
+ publicFolder: "static",
},
// ...
schema: {
collections: [
{
ui: {
router: ({ document }) => {
return document._sys.breadcrumbs[0]
},
},
name: "post",
label: "Posts",
format: "mdx",
path: "content/blog",
fields: [
{
type: "string",
name: "title",
label: "Title",
isTitle: true,
required: true,
},
+ {
+ type: "datetime",
+ name: "date",
+ label: "Date",
+ },
{
type: "rich-text",
name: "body",
label: "Body",
isBody: true,
},
+ {
+ type: "string",
+ name: "description",
+ label: "Description",
+ },
],

Updating your images

You'll need to reupload you're images to match our new media directory.

TinaCMS does not currently support relative image directories (e.g. those used for the original blog). You can either port your images by re-uploading them or changing the url to match our media folder.

For example the new image in content/blog/hello-world/index.mdx will look like this.

- ![Chinese duck egg](./salty_egg.jpg)
+ ![Chinese duck egg](/images/salty_egg.jpg)

You'll also need to move the existing images into the new folder we defined.

mkdir static/images/
cp content/blog/hello-world/salty_egg.jpg static/images/salty_egg.jpg

Reformatting your markdown

Note: You may need to update other elements on your site. For unsupported markdown elements in Tina, refer to our guide.
- - Red
+ * Red
- - Green
+ * Green
- - Blue
+ * Blue
* Red
* Green
* Blue
- - Red
+ * Red
- - Green
+* Green
- - Blue
+* Blue
```markdown
+ * Red
- - Green
+ * Green
- - Blue
+ * Blue
* Red
* Green
* Blue
- - Red
+ * Red
- - Green
+* Green
- - Blue
+* Blue
```

We should be able to read and edit our existing pages in TinaCMS now.

Styling

We'll add some CSS to fix the images in our articles since they aren't being handled by to fix the width of our images since they're no longer being processed by Gatsby.

Add the following to the top of src/style.css. This will resize any images in our blog.

+ img {
+ max-width: 630px;
+ }

Congratulations! Your Gatsby MDX blog is now set up with Tina. Run yarn develop to test it out.

Warning - If you do decide to add visual editing you will need to swap any custom MDX plugins you're using

Up until now we've only set up TinaCMS as an editor for our markdown files. The display logic is still being handled by Gatsby's plugins.

There are some pros and cons to using Gatsby's MDX plugin instead of Tina's.

Pros:

  • You can use your existing markdown plugins

Cons:

  • You won't be able to use react components in your markdown files
  • You won't be get contextual editing when editing your markdown files

Generally, we recommend using Tina's GraphQL API to load your pages, which we'll do now.

Because we'll be using Tina's graphql client for this approach we no longer need to skip it. In fact we'll need it to retrieve the GraphQL queries required for visual editing.

export default defineConfig({
- client: { skip: true },
// ...
Generating the pages

First, we'll new types for the response from Tina's GraphQL API and remove the existing ones. Modify the types in src/types.ts to reflect the new data we'll be getting back from Tina's API.

- type PageData = {
- id: string
- internal: {
- contentFilePath: string
- }
- fields: {
- slug: string
- }
- }
-
- export { AllPageData, PageData }
+ import client from "../tina/__generated__/client"
+ import { Post } from "../tina/__generated__/types"
+
+ type PostResponse = Awaited<ReturnType<typeof client.queries.post>>
+ type AllPostResponse = Awaited<ReturnType<typeof client.queries.postConnection>>
+ type BlogPost = Partial<Post> & {
+ slug: string
+ relativePath: string
+ }
+
+ export { AllPostResponse, BlogPost, PostResponse }

And we'll also want to import them at the top of our blog-post page.

Then we'll add a helper to map the response from Tina's GraphQL API to a to a schema similar to the existing page data.

+ import { AllPostResponse, BlogPost } from "./src/types"
+ const mapResponse = (postResponse: AllPostResponse): BlogPost[] => {
+ const mappedResponse = postResponse.data.postConnection.edges.map(edge => {
+ const {
+ title,
+ body,
+ _sys: { breadcrumbs, relativePath },
+ } = edge.node
+ return {
+ relativePath,
+ title,
+ body,
+ slug: breadcrumbs[0],
+ }
+ })
+ return mappedResponse
+ }

Next we'll update the createPages function to use Tina's GraphQL API to generate the pages and remove the existing call.

- import { AllPageData } from "./src/types"
import { AllPostResponse, BlogPost } from "./src/types"
+ import client from "./tina/__generated__/client"
//...
export const createPages: GatsbyNode["createPages"] = async ({
graphql,
actions,
reporter,
}) => {
const { createPage } = actions
const result = await client.queries.postConnection()
const posts: BlogPost[] = mapResponse(result)
+ const result = await client.queries.postConnection()
+ const posts: BlogPost[] = mapResponse(result)
+ // Get all markdown blog posts sorted by date
- const result = await graphql<mdxResponse>(`
- {
- allMdx(sort: { frontmatter: { date: ASC } }, limit: 1000) {
- nodes {
- id
- internal {
- contentFilePath
- }
- fields {
- slug
- }
- }
- }
- }
- `)
- if (result.errors) {
- reporter.panicOnBuild(
- `There was an error loading your blog posts`,
- result.errors
- )
- return
- }
- const posts = result!.data!.allMdx.nodes

Using the response from Tina's GraphQL API we'll change the way that pages get generated

- if (posts.length > 0) {
- posts.forEach((post, index) => {
- const previousPostId = index === 0 ? null : posts[index - 1].id
- const nextPostId = index === posts.length - 1 ? null : posts[index + 1].id
- createPage({
- path: post.fields.slug,
- component: `${blogPost}?__contentFilePath=${post.internal.contentFilePath}`,
- context: {
- id: post.id,
- previousPostId,
- nextPostId,
- },
- })
- })
- }
+ posts.map((post, index) => {
+ if (posts.length > 0) {
+ const previousPostPath =
+ index === 0 ? null : posts[index - 1].relativePath
+ const nextPostPath =
+ index === posts.length - 1 ? null : posts[index + 1].id
+ createPage({
+ path: post.slug,
+ component: blogPost,
+ context: {
+ relativePath: post.relativePath,
+ previousPostPath,
+ nextPostPath,
+ },
+ })
+ }
+ })

Updating the blog post page

First we'll define our types inside of src/types.ts.

+ type BlogPostPageProps = {
+ pageContext: BlogPostPageContext
+ }
+
+ type BlogPostPageContext = {
+ relativePath: string
+ previousPostPath: string
+ nextPostPath: string
+ }
- export { AllPostResponse, BlogPost, PostResponse }
+ export { AllPostResponse, BlogPost, PostResponse, BlogPostPageContext, BlogPostPageProps }

We'll use a static query to get the data for our blog post page template.

Add a static query to get the data for the page using Tina.

+ import { Post } from "../../tina/__generated__/types"
+ import { BlogPostPageProps, PostResponse } from "../types"
//...
+ const mapToPostLinkData = (
+ response: PostResponse
+ ): Partial<Post> & { slug: string; title: string } => {
+ return {
+ title: response.data.post.title,
+ slug: response.data.post._sys.breadcrumbs[0],
+ }
+ }
+ const getPostLinkData = async (path: string) => {
+ if (!path) return null
+ const post = await client.queries.post({
+ relativePath: path,
+ })
+ return mapToPostLinkData(post)
+ }
+ export async function getServerData({ pageContext }: BlogPostPageProps) {
+ const { relativePath, nextPostPath, previousPostPath } = pageContext
+ const { data, query, variables }: PostResponse = await client.queries.post({
+ relativePath: relativePath,
+ })
+ const nextPageData = await getPostLinkData(nextPostPath)
+ const previousPageData = await getPostLinkData(previousPostPath)
+
+ return {
+ props: {
+ query,
+ data,
+ variables,
+ nextPageData,
+ previousPageData,
+ },
+ }
+ }

We'll also update the page query to exclude the markdown from the query since we'll be using TinaCMS to populate the page instead.

export const pageQuery = graphql`
- query BlogPostBySlug(
- $id: String!
- $previousPostId: String
- $nextPostId: String
- ) {
query {
site {
siteMetadata {
title
}
}
- mdx(id: { eq: $id }) {
- id
- frontmatter {
- title
- date(formatString: "MMMM DD, YYYY")
- description
- }
- }
- previous: mdx(id: { eq: $previousPostId }) {
- fields {
- slug
- }
- frontmatter {
- title
- }
- }
- next: mdx(id: { eq: $nextPostId }) {
- fields {
- slug
- }
frontmatter {
title
}
}
}
`

Now that we've configured our page with a new data source we can use th useTina hook to implement visual editing.

first update the page props for BlogPostTemplate. We'll add in our server fetched data and pull that in using the useTina hook

+ import { useTina } from 'tinacms/dist/react'
//...
- const BlogPostTemplate = ({
- data: { previous, next, site, mdx: post },
- location,
- children,
-}) => {
+ const BlogPostTemplate = ({
+ serverData,
+ data: { site },
+ location
+ }) => {
+ const { query, variables, nextPageData, previousPageData } = serverData
+ const { data: tinaData } = useTina({
+ data: serverData.data,
+ query,
+ variables,
+ })
//...

Then we'll swap out all of the existing data with the data we get back from Tina. Note the additon of the tinaField property, which is used to add contextual editing for each of the fields.

+ import {useTina } from 'tinacms/dist/react'
+ import { TinaMarkdown } from "tinacms/dist/rich-text";
//...
<header>
- <h1 itemProp="headline">{post.frontmatter.title}</h1>-
- </p>{post.frontmatter.date}<<p>
+ <h1 data-tina-field={tinaField(tinaData.post, 'title')} itemProp="headline">{tinaData.post.title}</h1>
+ <p data-tina-field={tinaField(tinaData.post, 'date')}>{tinaData.date}</p>
</header>
- {children}
+ <main data-tina-field={tinaField(tinaData.post, "body")}>
+ <TinaMarkdown content={tinaData.post.body} />
+ </main>
<hr />
<footer>
<Bio />
</footer>
</article>
<nav className="blog-post-nav">
<ul
style={{
display: `flex`,
flexWrap: `wrap`,
justifyContent: `space-between`,
listStyle: `none`,
padding: 0,}}>
<li>
- {previous && (
- <Link to={previous.fields.slug} rel="prev">
- ← {previous.frontmatter.title}
+ {previousPageData && (
+ <Link to={previousPageData.slug} rel="prev">
+ ← {previousPageData.title}
</Link>
)}
</li>
<li>
- {next && (
- <Link to={next.fields.slug} rel="next">
- {next.frontmatter.title} →
+ {nextPageData && (
+ <Link to={nextPageData.slug} rel="next">
+ {nextPageData.title} →

There's one other step we'll do. Unfortunately, our date isn't being formatted using by the graphql query. To fix this we'll use a library to format our date.

yarn add dateformat

Then we'll add a useEffect statement to update the date when it date changes. We're using useEffect here so that the date will be recomputed when we use the visual editor. Using useState will cause the date to update when our data source changes.

+ import dateFormat from "dateformat"
//...
const BlogPostTemplate = ({ serverData, data: { site }, location }) => {
const { query, variables, nextPageData, previousPageData } = serverData
const { data: tinaData } = useTina({
data: serverData.data,
query,
variables,
})
const siteTitle = site.siteMetadata?.title || `Title`
+ const [formattedDate, setFormattedDate] = React.useState(
+ dateFormat(tinaData.post.date, "mmmm dd, yyyy")
+ )
+ React.useEffect(() => {
+ setFormattedDate(dateFormat(tinaData.post.date, "mmmm dd, yyyy"))
+ }, [tinaData.post.date])
return (
<Layout location={location} title={siteTitle}>
<article
className="blog-post"
itemScope
itemType="http://schema.org/Article"
>
<header>
<h1 data-tina-field={tinaField(tinaData.post, "title")} itemProp="headline">{tinaData.post.title}</h1>
- <p data-tina-field={tinaField(tinaData.post, "date")}>{post.frontmatter.date}</p>
+ <p data-tina-field={tinaField(tinaData.post, "date")}>{formattedDate}</p>
//...

editing an article's date using the visual editor

Updating the home page

We also need to update the homepage to reflect content changes, as it was previously populated using gatsby-mdx. Make the following updates to src/pages/index.tsx:

export const pageQuery = graphql`
query {
site {
siteMetadata {
title
}
}
}
- allMdx(sort: { frontmatter: { date: DESC } }) {
- nodes {
- fields {
- slug
- }
- frontmatter {
- date(formatString: "MMMM DD, YYYY")
- title
- description
- }
- }
- }
}
`
}

On the home page we'll need to add a server side fetch to get the full list of articles using TinaCMS.

+ import client from "../../tina/__generated__/client"
//...
+ export async function getServerData() {
+ const posts = await client.queries.postConnection()
+ return {
+ props: {
+ posts: posts.data.postConnection.edges.map(edge => {
+ const {
+ title,
+ body,
+ date,
+ _sys: { breadcrumbs },
+ description,
+ } = edge.node
+ return { title, body, date, slug: breadcrumbs[0], description }
+ }),
+ },
+ }
+ }
- const BlogIndex = ({ data, location }) => {
+ const BlogIndex = ({ data, location, serverData }) => {
const siteTitle = data.site.siteMetadata?.title || `Title`
- const posts = data.allMdx.posts
+ const posts = serverData.posts
//...
+ import formatDate from "dateformat"
//...
<ol style={{ listStyle: `none` }}>
{posts.map(post => {
- const title = post.frontmatter.title || post.fields.slug
+ const title = post.title || post.slug
return (
- <li key={post.fields.slug}>
+ <li key={post.slug}>
<article
className="post-list-item"
itemScope
itemType="http://schema.org/Article"
>
<header>
<h2>
- <Link to={post.fields.slug} itemProp="url">
+ <Link to={post.slug} itemProp="url">
<span itemProp="headline">{title}</span>
</Link>
</h2>
- <small>{post.frontmatter.date}</small>
+ <small>{post.date}</small>
</header>
<section>
<p
dangerouslySetInnerHTML={{
- __html: post.frontmatter.description,
+ __html: post.description,
}}
itemProp="description"/>
//...
- export const Head = ({ data: { mdx: post } }) => {
+ export const Head = ({ serverData }) => {
return (
<Seo
- title={post.title}
+ title={serverData.data.post.title}
- description={post.description}
+ description={serverData.data.description}
/>
)
}
Note: we don't need to use the useTina hook here because the homepage is static.

We'll also format the date in this file. Note that we don't need to use useEffect here because the page is static.

//...
<ol style={{ listStyle: `none` }}>
{posts.map(post => {
+ const formattedDate = formatDate(post.date, "mmmm mm, yyyy")
const title = post.title || post.slug
return (
<li key={post.slug}>
<article
className="post-list-item"
itemScope
itemType="http://schema.org/Article"
>
<header>
<h2>
<Link to={post.slug} itemProp="url">
<span itemProp="headline">{title}</span>
</Link>
</h2>
- <small>{post.date}</small>
+ <small>{formattedDate}</small>
</header>
<section>
<p
dangerouslySetInnerHTML={{
__html: post.description,
}}
itemProp="description"
//...

The final step for enabling contextual editing is to configure the routing property of our collection. This setting will ensure that we navigate to the correct page when opening a file in TinaCMS's visual editor. Since each blog post is stored in its own folder within the content directory, we can use the first folder in the breadcrumbs array to determine the correct path.

schema: {
collections: [
{
+ ui: {
+ router: ({ document }) => {
+ return document._sys.breadcrumbs[0]
+ },
+ },

(Optional) Adding React components

You can also add custom React components in Gatsby. First, update the schema for your blog posts to define the new React component you plan to add.

schema: {
collections: [
{
ui: {
router: ({ document }) => {
return document._sys.breadcrumbs[0]
},
},
name: "post",
label: "Posts",
format: "mdx",
path: "content/blog",
fields: [
{
type: "string",
name: "title",
label: "Title",
isTitle: true,
required: true,
},
{
type: "datetime",
name: "date",
label: "Date",
},
{
type: "rich-text",
name: "body",
label: "Body",
isBody: true,
+ templates: [
+ {
+ name: "RichBlockQuote",
+ label: "Rich Block Quote",
+ fields: [
+ {
+ name: "children",
+ label: "Body",
+ type: "rich-text",
+ },
+ ],
+ },
+ ],
},
],
},
],
},

Next, we'll define how the custom component will look in blog-post.tsx. We'll be parsing the child of the component into our TinaMarkdown renderer to give us rich text editing capabilities.

+ const components = {
+ RichBlockQuote: props => {
+ return (
+ <blockquote>
+ <TinaMarkdown content={props.children} />
+ </blockquote>
+ )
+ },
+ }

Setting the body to the built-in children property in React allows us to use the children of our React component as a value.

This has the added benefit of making our markdown easy to read. For example, check out the example below.

<RichBlockQuote>
### TinaCMS Rocks!
Go check out the starter template on [tina.io](https://tina.io/docs/introduction/using-starter/)
</RichBlockQuote>

The last thing you'll need to do is pass our component list to the components prop of our TinaMarkdown component.

return (
<Layout location={location} title={siteTitle}>
<article
className="blog-post"
itemScope
itemType="http://schema.org/Article"
>
<header>
<h1
data-tina-field={tinaField(tinaData.post, "title")}
itemProp="headline"
>
{tinaData.post.title}
</h1>
<p data-tina-field={tinaField(tinaData.post, "date")}>
{formattedDate}
</p>
</header>
<main data-tina-field={tinaField(tinaData.post, "body")}>
<TinaMarkdown content={tinaMarkdownContent} />
+ <TinaMarkdown content={tinaMarkdownContent} components={components} />

Last Edited: November 11, 2024