DEV Community

Cover image for From CMS To Contextual Editing
James Perkins for Tina

Posted on • Edited on • Originally published at tina.io

From CMS To Contextual Editing

Tina allows you as a developer to create an amazing editing experience. By default the editing experience is a more traditional CMS, where you login to a specific URL and you edit your content without being able to see the content till after you finish your changes. But, if you are looking for a more transparent real-time editing experience, Tina has a superpower, called Contextual Editing. Just as it sounds, you get instant feedback on the page as well as being able to preview the changes before publishing live to your site.

Example of Tina Context editing

Getting our project started

For this blog post we are going to use Tina’s example which is located in the Next.js official examples and allows us to use create-next-app.This example is based upon Next.js blog starter which is a markdown blog.

You can find the source code in the Next.js examples directory, https://github.com/vercel/next.js/tree/canary/examples/cms-tina.

## Create our new Tina site

npx create-next-app --example cms-tina tina-contextual-editing

## Move into our app.

cd tina-contextual-editing

## Open with your favorite editor
Enter fullscreen mode Exit fullscreen mode

If you want to learn how this example was created check out the blog post that covers getting started with Tina.

What does the Tina code do?

Before you add any code to this example, I want to cover how Tina is integrated and how it works.

.tina folder

You will find a .tina folder in the root of the project, this is the heart and brain of Tina in any project.

schema.ts

The schema file contains two important pieces of code defineSchema and defineConfig. The defineSchema allows you to define the shape of your content. If you have used a more traditional CMS before you may have done this via a GUI. However, given how Tina is tightly coupled with Git and we treat the filesystem as the “source of truth”, we take the approach of “content-modeling as code”.

If you look at the current defined schema, you will see that each one of the fields is related to the front matter contained within any of the posts in the _posts folder.

Moving on to the defineConfig, the defineConfig tells your project where the content is requested from, what branch to use, and any configuration to TinaCMS itself.

components

The components folder inside the .tina holds both our TinaProvider and DynamicTinaProvider; these two components wrap your application with the power of Tina. We will be heading back here later for some updates.

_generated__

This folder holds all the auto generated files from Tina, if you open this up you will see files that contain queries, fragments and types. You won’t need to make changes here but it’s good to know what you might find in there.

Before we add contextual editing, go ahead and launch the application using yarn tina-dev and navigate to http://localhost:3000/. You will notice that it is a static blog. Feel free to navigate around and get a feel for the blog.

Now if you navigate to http://localhost:3000/admin you will be presented with a screen to login, if you login you will land on the CMS dashboard. Selecting a collection on the left will bring you to a screen with all the current files in that space. Then selecting a file will allow you to edit it as you see fit.

Tina CMS Example

Adding Contextual Editing.

Now you have an understanding of both how the CMS works, and how the code behind is laid out we can start working on adding contextual editing to our project. What do we need to do to make contextual editing work?

  1. Update getStaticPaths and getStaticProps to use Tina’s graphql layer

  2. Update the page to use useTina and power the project props

  3. Update the TinaCMS configuration

Creating the getStaticPaths query

The getStaticPaths query is going to need to know where all of our markdown files are located. With our current schema you have the option to use getPostsList, which will provide a list of all posts in our _posts folder. Make sure your local server is running and navigate to http://localhost:4001/altair and select the Docs button. The Docs button gives you the ability to see all the queries possible and the variables returned:

So based upon the getPostsList we will want to query the sys which is the filesystem and retrieve the filename, which will return all the filenames without the extension.

query {
  getPostsList {
    edges {
      node {
        sys {
          basename
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you run this query in the GraphQL client you will see the following returned:

{
  "data": {
    "getPostsList": {
      "edges": [
        {
          "node": {
            "sys": {
              "filename": "dynamic-routing"
            }
          }
        },
        {
          "node": {
            "sys": {
              "filename": "hello-world"
            }
          }
        },
        {
          "node": {
            "sys": {
              "filename": "preview"
            }
          }
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding this query to our Blog.

The NextJS starter blog is served on the dynamic route /pages/posts/[slug].js. When you open the file you will see a function called getStaticPaths at the bottom of the file.

export async function getStaticPaths() {

....
Enter fullscreen mode Exit fullscreen mode

Remove all the code inside of this function and we can update it to use our own code. The first step is to add an import to the top of the file to be able interact with our graphql, and remove the getPostBySlug and getAllPosts imports we won’t be using:

//other imports
.....
- import { getPostBySlug, getAllPosts } from '../../lib/api'
+ import { staticRequest } from "tinacms";
Enter fullscreen mode Exit fullscreen mode

Inside of the getStaticPaths function we can construct our request to our content-api. When making a request we expect a query or mutation and then variables if needed to be passed to the query, here is an example:

staticRequest({
query: '...', // our query
}),

What does staticRequest do?”

It’s just a helper function which supplies a query to your locally-running GraphQL server, which is started on port 4001. You can just as easily use fetch or an http client of your choice.

We can use the getPostsList query from earlier to build our dynamic routes:

export async function getStaticPaths() {
  const postsListData = await staticRequest({
    query: `
      query {
        getPostsList {
          edges {
            node {
            sys {
              filename
              }
            }
          }
      }
    }
    `,
  })
  return {
    paths: postsListData.getPostsList.edges.map(edge => ({
      params: { slug: edge.node.sys.filename },
    })),
    fallback: false,
  }
}
Enter fullscreen mode Exit fullscreen mode

Quick break down of getStaticPaths

The getStaticPaths code takes the graphql query we created, because it does not require any variables we can send down an empty object. In the return functionality we map through each item in the postsListData.getPostsList and create a slug for each one.

Creating getStaticProps query

Creating the query

The getStaticProps query is going to deliver all the content to the blog, which is how it works currently with the code provided by Next.js. When we use the GraphQL API we will both deliver the content and give the content team the ability to edit it right in the browser.

We need to query the following things from our content api:

  • Title
  • Excerpt
  • Date
  • Cover Image
  • OG Image data
  • Author Data
  • Body content

Creating our Query

Using our local graphql client we can query the getPostDocument using the path to the blog post in question, below is the skeleton of what we need to fill out.

query BlogPostQuery($relativePath: String!) {
  getPostsDocument(relativePath: $relativePath) {
    # data from our posts.
  }
}
Enter fullscreen mode Exit fullscreen mode

We can now fill in the relevant fields we need to query, take special note of both author and ogImage which are grouped so they get queried as:

author {
  name
  picture
}
ogImage {
  url
}
Enter fullscreen mode Exit fullscreen mode

Once you have filled in all the fields you should have a query that looks like the following:

query BlogPostQuery($relativePath: String!) {
  getPostsDocument(relativePath: $relativePath) {
    data {
      title
      excerpt
      date
      coverImage
      author {
        name
        picture
      }
      ogImage {
        url
      }
      body
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you would like to test this out, you can add the following to the variables section at the bottom {"relativePath": "hello-world.md"}

Adding our query to our blog

Remove all the code inside of the getStaticProps function and we can update it to use our own code. Since these pages are dynamic, we’ll want to use the values we returned from getStaticPaths in our query. We’ll destructure params to obtain the slug, using it as a relativePath. As you’ll recall the “Blog Posts” collection stores files in a folder called _posts, so we want to make a request for the relative path of our content. Meaning for the file located at _posts/hello-world.md, we only need to supply the relative portion of hello-world.md.

export const getStaticProps = async ({ params }) => {
  const { slug } = params
  // Ex. `slug` is `hello-world`
  const variables = { relativePath: `${slug}.md` }
  // ...
}
Enter fullscreen mode Exit fullscreen mode

We’ll also want to call staticRequest to load our data for our specific page. You’ll also notice that we will return the query & variables from getStaticProps. We’ll be using these values within the TinaCMS frontend.

import { staticRequest } from 'tinacms'

So the full query should look like this:

export const getStaticProps = async ({ params }) => {
  const { slug } = params
  const variables = { relativePath: `${slug}.md` }
  const data = await staticRequest({
    query: query,
    variables: variables,
  })

  return {
    props: {
      data,
      variables,
    },
  }
}
Enter fullscreen mode Exit fullscreen mode

You may have noticed that our query isn’t there but we are referencing it. This is because we want to be able to reuse the query for our useTina hook. At the top of our file, after the imports you can add the query:

const query = `query BlogPostQuery($relativePath: String!) {
  getPostsDocument(relativePath: $relativePath) {
    data {
      title
      excerpt
      date
      coverImage
      author {
        name
        picture
      }
      ogImage {
        url
      }
      body
    }
  }
}`
Enter fullscreen mode Exit fullscreen mode

Adding useTina hook

The useTina hook is used to make a piece of Tina content editable. It is code-split so in production, this hook will pass through the data value. When you are in edit mode, it registers an editable form in the sidebar.

Adding imports

The first thing that needs to be done is import useTina, useEffect and useState. We can also remove a few imports that we will no longer be using.

import {useState, useEffect} from 'react'
import {useTina} from 'tinacms/dist/edit-state'
Enter fullscreen mode Exit fullscreen mode

Changing our props and adding UseTina

We are going to update our props from ({ post, morePosts, preview }) to (props). We are going to pass the props into our useTina hook. Now that has been updated with the props coming in we can use useTina .


- export default function Post({ post, morePosts, preview }) {
+ export default function Post( props ) {


+  const { data } = useTina({
+    query,
+    variables: props.variables,
+    data: props.data,
+  })
Enter fullscreen mode Exit fullscreen mode

As you can see, we are reusing our query from before and passing the variables, and data to useTina. This now means we can power our site using contextual editing. Congratulations your site now has superpowers!

Update our elements to use Tina data

Now we have access to our Tina powered data we can go through and update all of our elements to use Tina. You can replace each of the post. with data.getPostsDocument.data. and then replace the !post?.slug with !props.variables.relativePath when you are finished your return code should look like:

const router = useRouter()
-  if (!router.isFallback && !post?.slug) {
+  if (!router.isFallback && !props.variables.relativePath) {
    return <ErrorPage statusCode={404} />
  }
  return (
-   <Layout preview={preview}>
+   <Layout preview={false}>
      <Container>
        <Header />
        {router.isFallback ? (
          <PostTitle>Loading…</PostTitle>
        ) : (
          <>
            <article className="mb-32">
              <Head>
                <title>
-                  {post.title} | Next.js Blog Example with {CMS_NAME}
+                  {data.getPostsDocument.data.title} | Next.js Blog Example with {CMS_NAME}
                </title>
-                <meta property="og:image" content={post.ogImage.url} />
+                <meta property="og:image" content={data.getPostsDocument.data.ogImage.url} />
              </Head>
              <PostHeader
-                title={post.title}
-                coverImage={post.coverImage}
-                date={post.date}
-                author={post.author}
+                title={data.getPostsDocument.data.title}
+                coverImage={data.getPostsDocument.data.coverImage}
+                date={data.getPostsDocument.data.date}
+                author={data.getPostsDocument.data.author}
              />
-             <PostBody content={post.content} />              
+             <PostBody content={data.getPostsDocument.data.body} />
            </article>
          </>
        )}
      </Container>
    </Layout>

  )
Enter fullscreen mode Exit fullscreen mode

The final piece of Contextual Editing.

One piece of code that the original was doing was taking the markdown and turning it into HTML, inside of the getStaticPriops. We currently aren’t doing that with our body and just returning the string. Due to the nature of contextual editing, we need to make sure that we are always passing the latest content through the markdownToHtml function provided by the team at Next.js. This is where useState and useEffect come in, first create a content variable that is track by a state:

Note you could use another markdown to html package that would not require this, but in this example we want to reuse as much of the original code as possible to show how you could integrate with minimal code replacement.

const [content, setContent] = useState('')
Enter fullscreen mode Exit fullscreen mode

Then in useEffect we are going to parse our body to the markdownToHtml code provided by the Next.js team and set it to content.

useEffect(() => {
      const parseMarkdown = async () => {
        setContent(await markdownToHtml(data.getPostsDocument.data.body))
      } 
      parseMarkdown()
     }, [data.getPostsDocument.data.body])
Enter fullscreen mode Exit fullscreen mode

Now all we need to do is update our post body content from data.getPostsDocument.data.body to content . If you go ahead and test your application now, you can now edit on the page!

Next steps

Now that you have contextual editing here are a few things you can explore with Tina

How to keep up to date with Tina?

The best way to keep up with Tina is to subscribe to our newsletter, we send out updates every two weeks. Updates include new features, what we have been working on, blog posts you may of missed and so much more!

You can subscribe by following this link and entering your email: https://tina.io/community/

Tina Community Discord

Tina has a community Discord that is full of Jamstack lovers and Tina enthusiasts. When you join you will find a place:

  • To get help with issues
  • Find the latest Tina news and sneak previews
  • Share your project with Tina community, and talk about your experience
  • Chat about the Jamstack

Tina Twitter

Our Twitter account (@tina_cms) announces the latest features, improvements, and sneak peeks to Tina. We would also be psyched if you tagged us in projects you have built.

Top comments (0)