Bootstrap a Next.js App with Tailwind CSS

16 min read

In this post, we'll do some initial setup to prepare our project for the future posts in this series. We'll bootstrap a Next.js project, and we'll also install TailwindCSS and configure it to work with Next. We'll wrap up this post by adding some initial routes.

Create a Project with create-next-app

We'll use create-next-app with npx to create our project. I'm going to stick with npm as my package manager, so I'll include the --use-npm flag here.

npx create-next-app my-awesome-blog --use-npm

Once that's done, we'll want to work from the newly created directory.

cd my-awesome-blog

Add TypeScript Support

This part is optional, but I wanted to have TypeScript enabled in my project. Unlike create-react-app, we don't need to do anything during the initial bootstrapping step to add TypeScript support. With Next, we can add it at any point.

Next makes adding TypeScript super easy for us. First, we need to add the dependencies for TypeScript.

npm install --save-dev typescript @types/react @types/node

Then we can create an empty tsconfig.json file. We don't need to write any of the config ourselves, just adding the file is enough for Next to detect our intent to use TypeScript. The next time we run next dev, Next will add the TypeScript config for us (we'll look at that shortly).

touch tsconfig.json

Create an nvmrc File

This step is also optional, but I like to add a .nvmrc file so I can switch node versions as needed for various projects. I'll just write the current version of node to the file with the following command.

node -v > .nvmrc
Note

If you plan to use Next features like API Routes, or Middleware on Vercel (I do), you should run a version of node that will be supported in the runtime environment (I am). At the time of writing this, AWS lambda support for node only goes as high as 14.x.

Cleanup the Home Page

Next pre-populates index.js with some markup that we're not going to be using. So we can clean that up. Let's start by renaming some files from .js to .tsx. We'll rename pages/_app.js to pages/_app.tsx, and index.js to index.tsx.

We're going to start with a clean slate, so let's update pages/index.tsx to look like this.

export default function Home() {
  return (
    <div>
      <h1>My Awesome Blog</h1>
    </div>
  )
}

We're removing all the imports and taking the JSX down to a single div with an h1.

At this point, there is a good chance the TypeScript integration with your editor is pretty unhappy. We don't have any settings configured for TypeScript because our tsconfig.json is an empty file.

We'll let Next fill this in with default values for us. All we need to do to make this happen is run our site in dev mode with.

npm run dev

The result in the terminal will confirm the creation of the tsconfig.json file.

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
We detected TypeScript in your project and created a tsconfig.json file for you.

You can also open http://localhost:3000/ in a browser and see our very basic page with the single h1.

You can also open up the tsconfig.json file in the root of your project, and you'll see it has been populated for us.

Add Our First Route In Next

Create A Blog Entry Route

Now that we're up and running, let's create our custom route. Next uses filesystem based routing, so creating new routes is a matter of creating files and folders where our files export a default React component.

We want the entry page for our blog to be available at the /blog path. To accomplish this, we'll create a file at pages/blog/index.tsx with the following content.

export default function Blog() {
  return (
    <div>
      <h1>Blog Post Listing</h1>
    </div>
  )
}

List Blog Posts with getStaticProps

We're going to want our list of blog posts available to our page component as props. We'll use getStaticProps to build up these props at build time and pass them into the component.

For now, let's create a hard-coded version of getStaticProps. This will give us a chance to focus on the types and data flow before we dive into reading markdown files from the filesystem.

Define Types for Blog

Let's start with two interfaces at the top of pages/blog/index.tsx.

interface PostSummary {
  slug: string
  title: string
}

interface BlogProps {
  posts: PostSummary[]
}

Each post on this page will be represented by a PostSummary object. For now, it's a slug and a title. The slug represents the URL for the post. So with the slug i-love-next, we'll end up navigating to /blog/i-love-next to see the post.

We also have the interface for our page component's props, which will have a single array of PostSummary objects.

Use Props in Blog

Let's also update our Blog component to accept props.

-export default function Blog() {
+export default function Blog({ posts }: BlogProps) {

Now we can add some code to display the post list on the page.

export default function Blog({ posts }: BlogProps) {
  return (
    <div>
      <h1>Blog Post Listing</h1>
+     <ul>
+       {posts.map((post) => (
+         <li key={post.slug}>{post.title}</li>
+       ))}
+     </ul>
    </div>
  )
}

Now we have a ul and a map over the posts array to create our list items. We're using the slug as our key, and using the title as the list item's content.

At this point, you'll get an error when you run this. Our posts prop is undefined right now. Let's implement getStaticProps and pass in some posts.

Implement getStaticProps

We'll start by importing the GetStaticProps type from next at the top of pages/blog/index.tsx

import type { GetStaticProps } from 'next'

Now at the bottom of the same file, we'll add the following code.

export const getStaticProps: GetStaticProps<BlogProps> = async () => {
  const posts: PostSummary[] = [
    { slug: 'my-first-post', title: 'My First Post' },
    { slug: 'another-post', title: 'Another Post' },
    { slug: 'one-more', title: 'One More' },
  ]
  return {
    props: { posts },
  }
}

We're exporting a const called getStaticProps. We've applied the type we just imported. That type is a generic, so we can specify the type for our props as well, so we pass BlogProps in as the type parameter on the GetStaticProps type.

We assign an async function to this. There isn't any async code in there right now, but typically, there would be and we'll be adding some in a future step.

We define a hard-coded set of posts, and then return that in the props object that is part of the return object. Keep in mind that you need to return an object from getStaticProps and that object needs to contain a props object. You can't just return your props directly.

At this point, your page should display an unordered list with our fake posts.

Now let's look at how we can create a dynamic route to link these list items to.

Create a Dynamic Route for Individual Posts

From the blog index, we're going to want to link to the individual blog posts using the slug property in our URL.

Let's start by adding the links to our list. We'll be using Next's routing, so the first thing we need to do is import the Link component on pages/blog/index.tsx.

import Link from 'next/link'

From here, we'll use that in our rendered output to Link to each slug.

export default function Blog({ posts }: BlogProps) {
  return (
    <div>
      <h1>Blog Post Listing</h1>
      <ul>
        {posts.map((post) => (
-         <li key={post.slug}>{post.title}</li>
+         <li key={post.slug}>
+           <Link href={`/blog/${post.slug}`}>
+             <a>{post.title}</a>
+           </Link>
+         </li>
        ))}
      </ul>
    </div>
  )
}

The Link component takes an href, which we'll point to /blog/<SLUG> using string interpolation. Don't forget to add the anchor tag inside the Link component!

When we run this now, our list items will link to their respective routes. Clicking one of those links will navigate accordingly, and you'll be met with Next's default 404 page.

Let's create a dynamic route to handle these routes.

In our pages/blog directory, create the file pages/blog/[slug].tsx. The [slug] file name tells the router that it should match anything in the "slug" portion of the path and render this dynamic route file.

Let's start off with some static content in this new file.

export default function Post() {
  return (
    <div>
      <h1>Post Page</h1>
    </div>
  )
}

With this in place, we can now click on any of the slugs and we'll see our new "Post Page" heading rendered.

Let's read in the slug so we can display it on our page.

At the top of pages/blog/[slug].tsx We'll import the useRouter hook from next/router.

import { useRouter } from 'next/router'

Then in our Post component we can get the router instance and grab the slug from router's query property. We'll add the slug value to our return just to verify the behavior.

export default function Post() {
  const router = useRouter()
  const slug = router.query?.slug
  return (
    <div>
      <h1>Post Page</h1>
      <h2>{slug}</h2>
    </div>
  )
}

Now when we run our app and click on one of our list items, we should see the Post page rendered, with the slug shown in the h2.

This is running strictly in the client, like a standard SPA (single-page-app). It works and from here, we could use the slug value to make a fetch request for content for our post, but since we'll be serving up static content, let's refactor this dynamic route component to support static generation.

For this, we'll need 2 pieces. We'll implement getStaticProps to read in our slug at build time and pass it into the Post component as props. We also need to implement getStaticPaths to generate a file for each of the available slugs.

Let's start with getStaticProps since we've already seen an example of this.

We'll update pages/blog/[slug].tsx to use getStaticProps.

First, add an import for the GetStaticProps type.

import type { GetStaticProps } from 'next'

We can also define a type for our props by adding the PostProps interface.

interface PostProps {
  slug: string
}

Then at the bottom of the file, let's add our getStaticProps implementation. Export the function from the file and in it we'll return our object that contains props and include slug as the only prop for now. We're using the context argument that is passed into getStaticProps and pulling slug off the params property. The as string here stops TypeScript from complaining that context.params?.slug is of type string | string[]. For our use case, we'll only ever have one slug, so this is fine.

export const getStaticProps: GetStaticProps<PostProps> = async (context) => {
  const slug = context.params?.slug as string
  return {
    props: {
      slug,
    },
  }
}

Now that we have that ready to go, let's update the component to use our slug prop instead of the useRouter approach.

We can remove useRouter, and the related code and update the component to specify its props.

import type { GetStaticProps } from 'next'
-import { useRouter } from 'next/router'

interface PostProps {
  slug: string
}

-export default function Post() {
+export default function Post({ slug }: PostProps) {
- const router = useRouter()
- const slug = router.query?.slug
  return (
    <div>
      <h1>Post Page</h1>
      <h2>{slug}</h2>
    </div>
  )
}

We can't verify any of this in a browser yet. If we try to run this, we'll receive the following error from Next.

Error: getStaticPaths is required for dynamic SSG pages and is missing for '/blog/[slug]'.
Read more: https://nextjs.org/docs/messages/invalid-getstaticpaths-value

In order for these dynamic routes to work, we need to define our getStaticPaths function.

Just below the getStaticProps definition, let's add the following code.

export const getStaticPaths: GetStaticPaths = async () => {
  return {
    paths: [],
    fallback: true,
  }
}

With this in place, we can run this and should see our individual pages, complete with slug values being displayed. Note the fallback property here, which is set to true. This tells Next that if a path isn't defined, to fallback to server-side rendering.

This isn't what we're going for here. We're going to statically generate all our pages during build time. If we switch this fallback property to false, visiting one of our pages will result in a 404.

Let's define our paths by returning an object for each slug we want to support (hard-coded for now). For each slug, we'll create an object that looks like this, and make it part of our paths array.

{
  params: {
    slug: 'SLUG VALUE HERE'
  }
}

Since we're supporting our 3 hard-coded slugs, getStaticPaths should look like this.

export const getStaticPaths: GetStaticPaths = async () => {
  return {
    paths: [
      { params: { slug: 'my-first-post' } },
      { params: { slug: 'another-post' } },
      { params: { slug: 'one-more' } },
    ],
    fallback: true,
  }
}

With this in place, we can run our site and we'll be able to navigate to each individual post.

If we run npm run build we'll also see a statically generated page for each of these slugs as part of our build-time output.

└ ● /blog/[slug]                           321 B          70.5 kB
    ├ /blog/my-first-post
    ├ /blog/another-post
    └ /blog/one-more

Read Content from the File System

Ultimately, our goal is to write our content in MDX and our pages generated statically by Next. Even though we're currently serving up mostly empty pages, we've actually gotten a good bit of the groundwork for this in place.

Let's tackle our next major step now and replace our hard-coded slugs with data pulled from the filesystem. We'll consume the markdown itself in a future post. For now, we're most concerned with finding and reading in some files.

We're going to keep it simple by working with a few assumptions.

  1. Our content will all be stored in a content directory at the root of our project.
  2. The file structure in content will be flat, meaning there will only be markdown files and no subdirectories.
  3. Our markdown files will all end in the .md extension.
  4. The file names will be kebob-cased and the file name will act as the slug. So my-post.md will be served at /blog/my-post.

Create Placeholder Files

With these assumptions in mind, let's get some sample content files created.

First, create a new directory called content.

Inside content, let's create a few files. You can create whatever files you'd like, but I'm going to be using these example files.

  • hello-world.md
  • something-else.md
  • and-another-one.md
  • stuff-and-things.md

Generate Post Listing Based on Files

Let's start with pages/blog/index.tsx and replace our hard-coded list of files with a list of the files in our content directory.

Since we'll need to read information from our content directory for the post listing as well as from individual posts, let's put this code in a separate utils.ts file in the root of our project.

At the top of our new utils.ts file, let's import fs and we'll also pull in join from the path module.

import fs from 'fs'
import { join } from 'path'

After our imports, let's create a variable to hold our content path. We'll use the current working directory and join it with content, this will provide a path that'll work for our build wherever it happens to be running.

const postDirectory = join(process.cwd(), 'content')

From there, let's add a function export called getAllPostSlugs. In it, we'll make a call to fs.readdirSync with our postDirectory value and map over the results, replacing the .md file extension with nothing to return an array of slug values.

export function getAllPostSlugs() {
  return fs.readdirSync(postDirectory).map((s) => s.replace('.md', ''))
}
Note
This is intentionally simplistic. You can make this code far more robust and add whatever logic you'd like to account for improperly formatted files names, additional extensions (i.e. `.mdx`), nested directories, etc.

With this in place, we can pull this into pages/blog/index.tsx.

import { getAllPostSlugs } from '../../utils'

And with our utility function imported, we can update getStaticProps to use this instead of our hard-coded array of PostSummary objects.

export const getStaticProps: GetStaticProps<BlogProps> = async () => {
- const posts: PostSummary[] = [
-   { slug: 'my-first-post', title: 'My First Post' },
-   { slug: 'another-post', title: 'Another Post' },
-   { slug: 'one-more', title: 'One More' },
- ]
+ const posts: PostSummary[] = getAllPostSlugs().map((slug) => ({
+   slug,
+   title: slug,
+ }))
  return {
    props: { posts },
  }
}

We still need the match the expected shape defined by our PostSummary type, so for now, we'll just use the slug from our utility as both the slug and the title.

Generate Static Paths Based on Files

Now that we have our listing being generated based on the files in our content directory, let's do the same for getStaticPaths in pages/blog/[slug].tsx.

Once again, we'll start with the import.

import { getAllPostSlugs } from '../../utils'

Then we'll update getStaticPaths to use the utility.

export const getStaticPaths: GetStaticPaths = async () => {
+ const paths = getAllPostSlugs().map((slug) => ({ params: { slug } }))
  return {
-   paths: [
-     { params: { slug: 'my-first-post' } },
-     { params: { slug: 'another-post' } },
-     { params: { slug: 'one-more' } },
-   ],
+   paths,
    fallback: true,
  }
}

We're mapping over getAllPostSlugs() again, this time generating a path object with slug as our lone value in the path params.

And now when we run npm run build, we'll see static generation for each file included in content.

 ● /blog/[slug] (369 ms)                  321 B          70.5 kB
    ├ /blog/and-another-one
    ├ /blog/hello-world
    ├ /blog/something-else
    └ /blog/stuff-and-things

Conclusion

We covered a lot of ground here. There is still a lot to be done to make this a complete blog that we can publish and use to share our thoughts, but we have a solid foundation to build on. In upcoming posts, we'll get TailwindCSS installed and configured, build some React components, integrate MDX to read our markdown files and display their content and more. Stay tuned for the next installment in this series.

Hot Tip
Check back next week for the next article in the series, Configure Tailwind CSS for a Next.js App!