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"+ }}
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.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",},// ...
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",+ },],
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
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 }
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 } = 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 }
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"><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} →
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 } = 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 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.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"/>//...
- 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.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