TinaCMS does not officially support Gatsby. We recommend migrating your Gatsby site to a well supported framework such as Next.JS instead.
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.
There are a few limitations to the approach outlined in this guide.
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-blogcd gatsby-mdx-example-blog
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
other
yarn
as your package manageryes
public
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"+ }}
First we'll configure where our images get stored and update the schema so that we're ready to work with markdown files.
Open tina/config.ts
and make the following changes.
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.ioclientId: process.env.NEXT_PUBLIC_TINA_CLIENT_ID,// Get this from tina.iotoken: process.env.TINA_TOKEN,build: {outputFolder: "admin",publicFolder: "public",},media: {tina: {- mediaRoot: "",+ mediaRoot: "images"- publicFolder: "public",+ publicFolder: "static",},// ...
Next we'll add the existing frontmatter fields to our schema.
We'll also change the path
to point to our existing blogs
schema: {collections: [{ui: {router: ({ document }) => {return document._sys.breadcrumbs[0]},},name: "post",label: "Posts",format: "mdx",- path: "content/posts",+ 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",+ },],
You'll need to reupload your 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
As the hello world sample uses a list type that is unsupported by TinaCMS, we'll update the lists to the supported format manually.
Make the following changes to content/blog/hello-world/index.mdx
.
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.
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:
Cons:
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 },// ...
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 }
Using these types, we'll add a helper to map out the response from Tina's GraphQL API. This will give the page data a similar format to the response from the GraphQL queries we're replacing.
+ 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 } = actionsconst 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,+ },+ })+ }+ })
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 }
Next 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 the 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 addition 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"><ulstyle={{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} →
Don't forget to update the Head component with data from the server as well.
- 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}/>)}
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
to update the date when the 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 } = serverDataconst { 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}><articleclassName="blog-post"itemScopeitemType="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>//...
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 homepage, we’ll need to implement a server-side fetch to retrieve the full list of articles through 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 }+ }),+ },+ }+ }
Then we'll add the server side data to the component.
- 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//...
Finally, we'll use the server side data to populate the landing page. Make the following changes to src/pages/index.tsx
.
<ol style={{ listStyle: `none` }}>{posts.map(post => {- const title = post.frontmatter.title || post.fields.slug+ const title = post.title || post.slugreturn (- <li key={post.fields.slug}>+ <li key={post.slug}><articleclassName="post-list-item"itemScopeitemType="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><pdangerouslySetInnerHTML={{- __html: post.frontmatter.description,+ __html: post.description,}}itemProp="description"/>//...
We'll also format the date in this file.
Note: we don't need to use the useTina
hook here because the homepage is static.
+ import formatDate from "dateformat"//...<ol style={{ listStyle: `none` }}>{posts.map(post => {+ const formattedDate = formatDate(post.date, "mmmm mm, yyyy")const title = post.title || post.slugreturn (<li key={post.slug}><articleclassName="post-list-item"itemScopeitemType="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><pdangerouslySetInnerHTML={{__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]+ },+ },
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}><articleclassName="blog-post"itemScopeitemType="http://schema.org/Article"><header><h1data-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
© TinaCMS 2019–2024