DEV Community

Cover image for Build a Linktree clone in under 1 hour with Strapi, Next.js and GraphQL
Patrick Göler von Ravensburg
Patrick Göler von Ravensburg

Posted on • Edited on • Originally published at pgvr.dev

Build a Linktree clone in under 1 hour with Strapi, Next.js and GraphQL

Building a basic Linktree clone seems rather simple which is perfect for showcasing a bunch of technologies and how they work together. Strapi, GraphQL and Next.js are the main building blocks for this project while Tailwind and GraphQL Codegen help with efficiency and developer experience.

First, Strapi will be set up together with GraphQL and the necessary data models to have users and buttons. Second, Next.js with Typescript and the Apollo Client will be set up together with the GraphQL Code Generator, and finally, the UI will be built using Tailwind. Feel free to skip ahead as this project includes a lot of setting stuff up :)

The project is on Github and I also made a video on Youtube about this project, feel free to check those out.

Backend with Strapi

To begin with I like to set up the project folder as a yarn monorepo. To do that run these commands in your terminal to create a folder and initialize the project.

mkdir linktree-clone
cd linktree-clone
yarn init -y
mkdir packages
Enter fullscreen mode Exit fullscreen mode

Every sub-folder like frontend, backend, ... will be in the packages folder. But first the package.json needs to be modified to make the yarn workspaces work.

{
  "name": "linktree-clone",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "workspaces": ["packages/*"]
}
Enter fullscreen mode Exit fullscreen mode

The important, new fields are private and workspaces, so make sure you have those.

Now Strapi can be installed, but make sure you are in the packages folder before installing it.

yarn create strapi-app backend --quickstart
Enter fullscreen mode Exit fullscreen mode

The quickstart option gives us a users and permissions out of the box. After installing Strapi will automatically launch and prompt you to create an admin user. Enter whatever you want here and you will be redirected to the Strapi home.

Now it is time to install the Strapi GraphQL plugin by going to the marketplace and installing it. I had some trouble installing this plugin, my suspicion is that the yarn workspaces are messing this up somehow. What helped me is to stop Strapi, run yarn to install packages again, launch Strapi with yarn develop and install the GraphQL plugin again. This worked for me, however, I hope it works for you the first time. Be patient during the installation, the server should reload automatically once it is done.

Now let's create an application user since we only have an admin user at the moment.

https://codingcastle.dev/static/images/linktree-clone/user-creation.jpg

Now that we have a user let's create the buttons collection. Go to Content Types Builder and create a new collection type called Button. A basic button needs these fields:

  • text: text
  • url: text
  • visible: boolean
  • user: Many-to-one relation with user from user-permissions (not admin-user)

You can also set text and url as required in the advanced field settings and provide a default value for visible. Save the collection and create a button which belongs to your user so we have some data to fetch. Don't forget to also publish the button after saving. Before fetching data the permissions for the buttons and users need to be adjusted. For development purposes we can allow all functions for these collections. For a real world scenario these would have to be double checked! So go to Settings → Roles → Public and allow everything for buttons and users (users are at the bottom).

https://codingcastle.dev/static/images/linktree-clone/button-permissions.jpg

The backend should now be ready for data to be fetched. The GraphQL playground can be found at "http://localhost:1337/graphql". Try this query which should give you your created button and username.

query Buttons {
    buttons {
        text
        url
        user {
            username
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And that's everything we need to set up a backend with Strapi. I love how easy it is to just start developing and not get bogged down by creating all the CRUD functions for the collections by yourself. The fact that users and permissons are pre-installed with the quickstarter are super nice too, even though the permissions don't really do anything for now. Plus, the GraphQL setup was super easy and now we have an always up-to-date GraphQL schema.

Frontend with Next.js

Setting up Next.js is really straight forward. From within the packages folder a Next.js app with Typescript can be installed with:

yarn create next-app frontend --example with-typescript
Enter fullscreen mode Exit fullscreen mode

The team behind Next.js provides a long list of examples to use different technologies with Next.js. Some examples are: Typescript, Tailwind, Apollo, pretty much all CMS's and more.

After the installation, open the frontend with VS Code (or preferred editor) and create this file inside the lib folder.

import { useMemo } from 'react'
import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'
import merge from 'deepmerge'

export const APOLLO_STATE_PROP_NAME = '__APOLLO_STATE__'

let apolloClient: ApolloClient<any>

function createApolloClient() {
  return new ApolloClient({
    ssrMode: typeof window === 'undefined',
    link: new HttpLink({
      uri: 'http://localhost:1337/graphql', // Server URL (must be absolute)
    }),
    cache: new InMemoryCache(),
  })
}

export function initializeApollo(initialState: any = null) {
  const _apolloClient = apolloClient ?? createApolloClient()

  // If your page has Next.js data fetching methods that use Apollo Client, the initial state
  // gets hydrated here
  if (initialState) {
    // Get existing cache, loaded during client side data fetching
    const existingCache = _apolloClient.extract()

    // Merge the existing cache into data passed from getStaticProps/getServerSideProps
    const data = merge(initialState, existingCache)

    // Restore the cache with the merged data
    _apolloClient.cache.restore(data)
  }
  // For SSG and SSR always create a new Apollo Client
  if (typeof window === 'undefined') return _apolloClient
  // Create the Apollo Client once in the client
  if (!apolloClient) apolloClient = _apolloClient

  return _apolloClient
}

export function addApolloState(client: any, pageProps: any) {
  if (pageProps?.props) {
    pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract()
  }

  return pageProps
}

export function useApollo(pageProps: any) {
  const state = pageProps[APOLLO_STATE_PROP_NAME]
  const store = useMemo(() => initializeApollo(state), [state])
  return store
}
Enter fullscreen mode Exit fullscreen mode

This is coming pretty much straight from the example repo with-apollo. Only the server URI is different and the function parameters are typed with any. The only thing left to do is to add the @apollo/client package. This file is more like a setup-once kind of thing, so I don't mind that the parameters are typed with any.

yarn add @apollo/client
Enter fullscreen mode Exit fullscreen mode

Now the entire app needs be wrapped with the ApolloProvider using the client we just created. In the pages folder create this special Next.js entry point file which wraps the entire application.

import { ApolloProvider } from "@apollo/client";
import { AppProps } from "next/app";
import { useApollo } from "../lib/apolloClient";

export default function App({ Component, pageProps }: AppProps) {
  const apolloClient = useApollo(pageProps);
  return (
    <ApolloProvider client={apolloClient}>
      <Component {...pageProps}></Component>
    </ApolloProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

At this point I also like to create all the files not being used at the moment. That would be everything inside the components folder, the interfaces folder, the utils folder, the api and users folder in pages and the about.tsx file.

GraphQL Codegen

Since we have a complete GraphQL schema from Strapi the GraphQL Code Generator can generate a complete set of types and hooks for us to use. Just head to the installation page and follow the instructions to install all the dependencies. In short it's this:

yarn add graphql
yarn add -D @graphql-codegen/cli
yarn graphql-codegen init
Enter fullscreen mode Exit fullscreen mode

The 3rd command initializes the repo. The schema is located at "../backend/exports/graphql/schema.graphql", operations and fragments should be at "lib/graphql/*/.graphql" and the output should be "lib/graphql/output.tsx". An introspection file is not needed and I called the script that runs the codegen "gen". For the rest the default options are fine. You can check out my video if you feel like these instructions are not clear enough or ask me about it in the comments.

Now the first query can be created in lib/graphql/ButtonsQuery.graphql.

query Buttons {
  buttons {
    text
    url
    user {
      username
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now the yarn gen command should generate types and more for this query but the first time I ran this command I got an error. After running yarn again to install dependencies yarn gen worked just fine. Keep in mind that every time the model or any .graphql files change the gen command needs to run again.

Tailwindcss

The official guide by Tailwindcss about the integration with Next.js is fantastic, so for the sake brevity I encourage you to follow the official guide.

However, there is one thing I would like to add. The Tailwind CSS Intellisense extension for VS Code is a must have in my opinion. This will give you auto-complete for Tailwind css classes which is especially great for beginners. If the intellisense doesn't work, try reloading the VS Code window. This usually fixes it.

Fetching the data

Thinking about what actually needs to be displayed it is quite obvious that the content is mostly static. On every user page there are just a few buttons that may not change from build to build. So we can initially render everything on the server. Every user page is accessible at the /username route. In Next this can be achieved by creating a new page called [username].tsx . This means that every route, unless occupied by another page, will map every /username to this page and the username parameter will be available in the props.

However, we only want to render a user page for actual users of the application. Next provides a function that allows us to limit which values we want to be allowed to map to this username page.

query Users {
  users {
    username
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case we can simply fetch all the users and only allow their usernames. Add a new query, run the code generator and add this function to the [username].tsx file.

export async function getStaticPaths() {
  const apolloClient = initializeApollo();

  const { data } = await apolloClient.query<UsersQuery>({
    query: UsersDocument,
  });

  return {
    paths: data?.users?.map((user) => ({
      params: {
        username: user?.username,
      },
    })),
    fallback: false,
  };
}
Enter fullscreen mode Exit fullscreen mode

This instantiates a new Apollo client, executes the users query and maps the usernames to the paths. So if there is no user with username "fred" and someone tries to access /fred then there will be a 404.

Now the button data needs to be fetched.

export async function getStaticProps({ params }: GetStaticPropsContext) {
  const apolloClient = initializeApollo();

  const { data } = await apolloClient.query<ButtonsQuery>({
    query: ButtonsDocument,
    variables: { username: params?.username },
  });

  return {
    props: { data, username: params?.username },
    revalidate: 1,
  };
}
Enter fullscreen mode Exit fullscreen mode

This looks really similar to the previous function. It receives the username as a parameter and we can use that to look up what buttons belong to that user. Then the button data and the username are returned as props to the component.

To filter the buttons by username the ButtonsQuery has to be adjusted though.

query Buttons($username: String!) {
  buttons(where: { user: { username: $username } }) {
    text
    url
    user {
      username
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With this data now being efficiently fetched on the server and available in the props, this project is now ready to tackle the (little) UI work there is.

type Props = {
  data: ButtonsQuery;
  username: string;
};

export default function Username({ data, username }: Props) {
  return (
    <div className="min-h-screen max-w-2xl mx-auto flex flex-col items-center py-10">
      <img
        className="rounded-full h-24 w-24 mb-4"
        // the source is a base64 string but dev.to doesn't allow these kind of strings it seems
        src=""
      />
      <h1 className="font-bold text-lg mb-10">@{username}</h1>
      <div className="space-y-4 w-full">
        {data.buttons?.map((btn) => (
          <Button key={btn?.text} text={btn?.text!} url={btn?.url!} />
        ))}
      </div>
      <div className="flex-1"></div>
      <svg
        className="w-28"
        display="block"
        height="100%"
        width="100%"
        data-testid="BlackGreenLogo"
        viewBox="0 0 137 25"
      >
        <title data-testid="svgTitle" id="title_0.0374982482169921">
          title
        </title>
        <desc data-testid="svgDescription" id="description_0.0374982482169921">
          description
        </desc>
        <g>
          <path
            d="m37.1 24.4v-20.8c0-0.3 0.2-0.6 0.6-0.6h1.9c0.3 0 0.6 0.2 0.6 0.6v20.9c0 0.3-0.2 0.6-0.601 0.6h-1.899c-0.3-0.1-0.6-0.3-0.6-0.7z"
            fill="#3D3B3C"
          ></path>
          <path
            d="m44.3 5.5v-2c0-0.3 0.2-0.6 0.6-0.6h2.1c0.3 0 0.6 0.2 0.6 0.6v2c0 0.3-0.2 0.6-0.6 0.6h-2.1c-0.4 0-0.6-0.2-0.6-0.6zm0.1 18.9v-14.8c0-0.3 0.3-0.6 0.6-0.6h1.8c0.3 0 0.6 0.2 0.6 0.6v14.9c0 0.3-0.199 0.6-0.6 0.6h-1.8c-0.3-0.1-0.6-0.3-0.6-0.7z"
            fill="#3D3B3C"
          ></path>
          <path
            d="m51.5 24.4v-14.8c0-0.3 0.2-0.6 0.6-0.6h1.8c0.3 0 0.6 0.2 0.6 0.6v1h0.1c0.6-0.8 1.6-1.5 3.1-1.9h0.1c2.3-0.3 4.1 0.2 5.3 1.5 1 1.1 1.601 2.5 1.601 4.399v9.899c0 0.3-0.2 0.601-0.601 0.601h-1.8c-0.3 0-0.6-0.199-0.6-0.601v-9.101c0-2.699-1.101-4.101-3.4-4.101-1.1 0-2.1 0.3-2.8 1s-1 1.601-1 2.7v9.5c0 0.3-0.2 0.6-0.601 0.6h-1.8c-0.399-0.096-0.599-0.296-0.599-0.696z"
            fill="#3D3B3C"
          ></path>
          <path
            d="m68.2 24.4v-20.8c0-0.3 0.2-0.6 0.6-0.6h1.8c0.3 0 0.6 0.2 0.6 0.6v12.6h0.1l6.3-6.9c0-0.2 0.2-0.3 0.3-0.3h2.3c0.5 0 0.7 0.6 0.399 0.9l-4.599 5.2c-0.2 0.2-0.2 0.5 0 0.7l5.6 8.4c0.2 0.399 0 0.899-0.5 0.899h-2c-0.199 0-0.398-0.101-0.5-0.199l-5.1-7.6h-0.1l-2.1 2.3c-0.1 0.102-0.1 0.2-0.1 0.4v4.6c0 0.3-0.2 0.6-0.6 0.6h-1.8c-0.3-0.2-0.6-0.4-0.6-0.8z"
            fill="#3D3B3C"
          ></path>
          <path
            d="M85.3,20v-8.4h-1.8c-0.3,0-0.602-0.2-0.602-0.6V9.6C83,9.3,83.2,9,83.5,9h1.9V5.7   c0-0.3,0.199-0.6,0.601-0.6h1.7c0.3,0,0.6,0.2,0.6,0.6V9h3.4c0.3,0,0.6,0.2,0.6,0.6V11c0,0.3-0.199,0.6-0.6,0.6h-3.4v8.1   c0,0.899,0.1,1.5,0.4,1.899C89,22,89.5,22.2,90.3,22.2H91c0.3,0,0.6,0.2,0.6,0.6v1.7c0,0.3-0.199,0.6-0.6,0.6h-0.9   c-1.699,0-2.898-0.398-3.699-1.199C85.7,22.9,85.3,21.7,85.3,20z"
            fill="#3D3B3C"
          ></path>
          <path
            d="M95.5,24.4V9.6C95.5,9.3,95.7,9,96,9h1.7c0.3,0,0.6,0.2,0.6,0.6v1.2H98.4c0.301-0.6,0.801-1.1,1.399-1.5   c0.601-0.4,1.2-0.5,1.9-0.5c0.3,0,0.698,0,1,0.1C103,9,103.1,9.3,103.1,9.5l-0.3,1.8c-0.1,0.3-0.399,0.5-0.7,0.4   c-0.199,0-0.398-0.1-0.699-0.1c-1.899,0-2.899,1.3-2.899,3.9v8.8c0,0.3-0.2,0.601-0.601,0.601H96C95.7,25,95.5,24.8,95.5,24.4z"
            fill="#3D3B3C"
          ></path>
          <path
            d="m116.6 19.8 1.301 1c0.199 0.2 0.301 0.5 0.101 0.7-0.601 0.9-1.399 1.7-2.3 2.4-1.102 0.699-2.5 1.1-4 1.1-2.2 0-4-0.8-5.4-2.3s-2.1-3.601-2.1-6.101c0-2.601 0.699-4.601 2.1-6.101s3.1-2.3 5.301-2.3c2.101 0 3.899 0.8 5.199 2.3 1.301 1.5 2 3.601 2 6.2v0.302c0 0.3-0.199 0.6-0.6 0.6h-10.4c-0.3 0-0.6 0.301-0.6 0.602 0.1 1.1 0.5 2.1 1.1 2.898 0.801 0.9 1.801 1.4 3.2 1.4 0.7 0 1.3-0.1 1.8-0.3s1-0.5 1.301-0.8c0.399-0.4 0.601-0.7 0.799-0.9 0.102-0.1 0.201-0.3 0.302-0.4 0.196-0.4 0.596-0.5 0.896-0.3zm-9.4-4.7h8.6c-0.1-1.199-0.399-2.3-1.1-3.1-0.7-0.9-1.802-1.3-3.2-1.3-1.3 0-2.4 0.5-3.102 1.4-0.798 0.9-1.198 1.9-1.198 3z"
            fill="#3D3B3C"
          ></path>
          <path
            d="m134 19.8 1.3 1c0.2 0.2 0.3 0.5 0.101 0.7-0.601 0.9-1.399 1.7-2.301 2.4-1.1 0.699-2.5 1.1-4 1.1-2.199 0-4-0.8-5.398-2.3-1.4-1.5-2.102-3.601-2.102-6.101 0-2.601 0.7-4.601 2.102-6.101 1.398-1.5 3.1-2.3 5.3-2.3 2.101 0 3.899 0.8 5.2 2.3 1.3 1.5 2 3.601 2 6.2v0.302c0 0.3-0.2 0.6-0.602 0.6h-10.4c-0.302 0-0.601 0.301-0.601 0.602 0.101 1.1 0.5 2.1 1.101 2.898 0.8 0.9 1.8 1.4 3.198 1.4 0.701 0 1.302-0.1 1.802-0.3s1-0.5 1.3-0.8c0.398-0.4 0.6-0.7 0.8-0.9 0.101-0.1 0.2-0.3 0.3-0.4 0.2-0.4 0.6-0.5 0.9-0.3zm-9.4-4.7h8.602c-0.102-1.199-0.4-2.3-1.102-3.1-0.699-0.9-1.8-1.3-3.199-1.3-1.301 0-2.399 0.5-3.101 1.4-0.8 0.9-1.2 1.9-1.2 3z"
            fill="#3D3B3C"
          ></path>
          <path
            d="m11 0.7c-0.5-0.9-1.8-0.9-2.3 0l-8.6 15.6c-0.4 0.8 0.2 1.7 1.1 1.7h5.8v5.9c0 0.6 0.5 1.1 1.1 1.1h3.4c0.6 0 1.1-0.5 1.1-1.1v-5.9h-1.6c-0.7 0-1.2-0.5-1.3-1.1 0-0.2 0-0.4 0.1-0.602l4.8-8.7-3.6-6.898z"
            fill="#39E09B"
          ></path>
          <path
            d="m18.6 0.7c0.5-0.9 1.8-0.9 2.3 0l8.6 15.6c0.4 0.8-0.2 1.7-1.1 1.7h-5.7v5.9c0 0.6-0.5 1.1-1.101 1.1h-3.599c-0.6 0-1.1-0.5-1.1-1.1v-5.9h1.6c0.7 0 1.2-0.5 1.3-1.1 0-0.2 0-0.4-0.1-0.602l-4.8-8.698 3.7-6.9z"
            fill="#28BF7B"
          ></path>
        </g>
      </svg>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

For the process and my thoughts behind building the UI I think a video is suited way better than a blog post, especially because there is nothing exciting happening in the UI. But here is the final Button component. To center a container in the middle of the screen a max-width can be used together margin: auto on the x axis. To center everything within that container display: flex and flex-direction column are suitable. The button component stretches the entire container, is green and the background changes to white with an animation while a green border remains. Things like the avatar picture or the Linktree logo can be "stolen" by inspecting their website, but since this is for educational purposes only, I hope they don't mind.

import NextLink from "next/link";

type Props = {
  text: string;
  url: string;
};

export default function Button({ text, url }: Props) {
  const link = url.includes("http") ? url : `https://${url}`;
  return (
    <NextLink href={link}>
      <button className="bg-green-300 text-white border-green-300 border-2 py-4 px-14 w-full hover:bg-white hover:text-green-300 transition-all duration-100">
        {text}
      </button>
    </NextLink>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's compare the final result to Linktree.

This is the official Linktree website:

https://codingcastle.dev/static/images/linktree-clone/linktree-official.png

And the final result looks something likes this:

https://codingcastle.dev/static/images/linktree-clone/final-result.png

So pretty close I would say 🎉

Note that this post did not go into detail about authentication and secure API permissions with Strapi, advanced GraphQL operations for e.g. modifying the buttons or logging in, or deploying the result. If you would like to see this project continue to address any of these topics, leave a comment and tell me what you would like to see. You can check out the project repository if you like.

As you can tell I'm just getting started with this whole blogging thing, so leaving a comment or follow would mean the world to me. 🌎

That's it for now, thanks for reading!

Top comments (0)