Bootstrap a Next.js App with Tailwind CSS
This is part 1 in the Build a Blog with Next, MDX, and Tailwind series
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
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.
- Our content will all be stored in a
content
directory at the root of our project. - The file structure in
content
will be flat, meaning there will only be markdown files and no subdirectories. - Our markdown files will all end in the
.md
extension. - 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', ''))
}
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.