Building a full-stack app with Next.js
In my last article I talked about SEO, SSR, and how Next.js can help us in those matters. In this new article (o series) I'd like to talk about how can we build a full-stack application using Next.js. Let's dive into it!
Okay so, what are we going to build? A simple app which allows you to create publications, edit and delete them, nothing more (don't you dare calling it a blog). These publications -from now on, posts- will be stored on a PostgreSQL database (we are not going to use any specific Postgres features, so I think you can easily use MySQL for example, or even Firestore). We'll be using TailwindCSS to create our layouts and style our app, and the whole application will be deployed as a Docker container into the cloud
Creating a Next.js app
First things first, lets scaffold our brand new Next.js app. For that, let's use the create-next-app
CLI tool via npx
. npx
is a tool that gets installed when you install node/npm. If you don't have npm installed, I recommend you to install it using Node Version Manager (nvm)
npx create-next-app my-nextjs-app
Once it's done, you should cd
into my-nextjs-app
and start the development server to see the initial state of the app navigating to http://localhost:3000/
cd my-nextjs-app && npm run dev
Okay great! Now we have our Next.js app up and running. Next thing we're going to do is install TailwindCSS.
TailwindCSS
TailwindCSS is a low-level, utility-first CSS framework, which means it doesn't provide any already made components (such as buttons, cards, or grids) like Bootstrap or MaterializeCSS do, for example. Nonetheless it offeres a lot of classes to help you build the components you want, as you want them. Because it provides a lot of CSS classes, it relies on PostCSS to remove all those classes you don't use in your project (among several other features it uses PostCSS for). What I'm trying to say is that setting up TailwindCSS is not as easy as installing Bootstrap (actually it can be that simple, but you lose a lot of cool features). Luckily enough, Next.js supports PostCSS out-of-the-box and also allows us to use a custom config file.
We now need to install TailwindCSS running the next command
npm install tailwindcss
Once installed, we need to initialize a TailwindCSS config file by running
npx tailwind init
This by default will create a file called tailwind.config.js
, so we can customize Tailwind as we want via this file. You can also run the previous command with the --full
flag to create a config file with all possible options available.
We have two more things to do before wrapping up our TailwindCSS setup. First, create a postcss.config.js
file and add the following content
module.exports = {
plugins: ["tailwindcss"]
}
If you read how to setup PostCSS to compile TailwindCSS stylesheet, you will find that you need to require()
the tailwindcss
plugin. However, when working with Next.js, it clearly says in the docs that you should provide PostCSS plugins as strings, and not functions.
Last thing we need to do before we move on is creating a styles.css
file and add Tailwind to our styles. Create a styles.css
file at the root level of your project (or if you prefer, create a src
directory and move your pages
directory inside of it, and then create styles.css
file inside src
), and add the following content:
@tailwind base;
@tailwind components;
@tailwind utilities;
This is not valid CSS syntax, so any linters you have will tell you about it (and this is why we need PostCSS to work with TailwindCSS).
Now, try importing your styles in your index.js
file
import "../styles.css";
export default function Home() {...}
Next.js will throw an error saying that you cannot import global styles from files other than your Custom <App>. If you want to add customized styles for a single component, you should create CSS Modules.
To fix this issue, create a _app.js
file in your pages
directory and add the following content
import '../styles.css'
export default function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
I won't explain right now what this _app.js
is, so if you'd like to know more about it I recommend you reading the official docs
We have now successfully set up TailwindCSS in our Next.js app! Add some Tailwind classes to your index page to check it works, after restarting your dev server
export default function Home() {
return (
<h1 className="m-4 text-center text-4xl text-red-500">Hello world!</h1>
);
}
Layout
Our layout won't be anything fancy - a simple header with some navigation links, main section, and a footer
/* styles.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
header,
footer {
min-height: 5vh;
}
main {
min-height: 90vh;
}
// _app.js
import "../styles.css";
export default function MyApp({ Component, pageProps }) {
return (
<React.Fragment>
<header>
<nav>
<ul className="flex justify-between items-center p-8 bg-blue-100">
<li>
<a href="/" className="text-blue-500 no-underline">
Home
</a>
<a href="/about" className="text-blue-500 no-underline p-8">
About
</a>
</li>
</ul>
</nav>
</header>
<main>
<Component {...pageProps} />
</main>
<footer className="bg-gray-300 flex justify-center items-center py-4">
<p>Next.js is so cool!</p>
</footer>
</React.Fragment>
);
}
That should look fairly okay. We are not doing anything pretty special, just creating a layout using Flexbox and assigning background colors. In case you noticed, I'm using React.Fragment
without importing React
- Next.js makes React
globally available for us to use (if you would like to write <Fragment>...</Fragment>
instead of <React.Fragment>...</React.Fragment>
then yes, you would have to add import { Fragment } from "react"
to your page).
If you tried to navigate to that about
page without success, is what was supposed to happen, as we don't have such page right now. To create it, create an about.js
file inside pages
and export default
your page component
// about.js
export default function About() {
return <h1>This is an about page</h1>
}
Models
Having our basic layout completed, it is time to show some data. Before moving forward, let's migrate our app to TypeScript so we can define some types for our models. To do so, we first need to stop the dev server, create a tsconfig.json
file at the root level, and finally rename every .js
file to .tsx
. If you try to restart the dev server right now, it will fail saying that some development dependencies are needed, which we need to install. Next.js will automatically populate the tsconfig.json
file, so we don't need to worry about configuring the TypeScript compiler - way to go, Next.js!
We don't have a database to fetch data from, so we are going to use some public fake API to retrieve data from. In a traditional React app, we would fetch some data in the componentDidMount
method or using the useEffect
hook. To make the most out of the SSR abilities Next.js offers us, we won't do that - instead we are going to use a special function Next.js expects us to export if we want our components to be Server-Side Rendered. This function is called getServerSideProps
export async function getServerSideProps() {
return {
props: { msg: "Hello world!" }
};
}
export default function Home(props) {
return <h1 className="m-4 text-center text-4xl text-red-500">{props.msg}</h1>;
}
With such a simple example, we are neither improving much our code nor adding any type-safety, so we first will fetch data from the API, and pass that as props to the component
export async function getServerSideProps() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const posts = await res.json();
return {
props: { msg: "Hello world!", posts }
};
}
export default function Home(props) {
console.log(props.posts);
return <h1 className="m-4 text-center text-4xl text-red-500">{props.msg}</h1>;
}
Baby steps. We are now fetching some fake posts, passing them as props to our component, and finally logging them in the console. But what about types? The posts we are getting back from the API have the following attributes: userId
, title
, body
, and id
. We don't care about the userId
attribute, so before passing the posts to our component, let's remove that field.
So, we are fetching a post data object with some structure, but we want them with some other structure. For that, let's define two different types.
type NetworkPost = {
userId: number;
id: number;
title: string;
body: string;
};
type Post = {
id: number;
title: string;
body: string;
};
We now have to treat the posts we are fetching from the API as NetworkPost
, and convert them to a list of Post
export async function getServerSideProps() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts");
const networkPosts = (await res.json()) as NetworkPost[];
const posts: Post[] = networkPosts.map(({ id, title, body }) => ({
id,
title,
body
}));
return {
props: { msg: "Hello world!", posts }
};
}
Great, this still should work the same way as before. We are now fetching some data from the network, treating those objects as some defined type, and passed them as props to our page component.
Inferring props based on returning type
Next.js provides us with some really cool and handy typing so we can infer component's props based on the returning type of our getServerSideProps
function
import { InferGetServerSidePropsType } from "next";
.
.
.
export default function Home(
props: InferGetServerSidePropsType<typeof getServerSideProps>
) {...}
Now our props
parameter will be type-checked based on what getServerSideProps
is returning in the props
field. We can of course destructure it and only pick those fields we need
export default function Home({
posts,
msg
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
console.log(posts);
return <h1 className="m-4 text-center text-4xl text-red-500">{msg}</h1>;
}
fetch
in the server
In case you didn't notice, we are using the fetch
function in the getServerSideProps
function, which is executed on the server (a Node.js server). But fetch
is a browser API, so how's that possible? Next.js already takes care of that, adding the necessary polyfills so we don't have to install any extra packages such as isomorphic-fetch
to have the fetch
API available in a Node.js environment.
Components
Although our page component is receiving posts as props, we are not displaying them quite yet. What are we now going to do is create some components to render our retrieved posts. To do so, we first need to create some files (and directories).
Create a components
directory next to your pages
, and inside that new directory create two files: PostList.tsx
and PostListItem.tsx
// PostList.tsx
import { Fragment } from "react";
import { PostListItem } from "./PostListItem";
type Props = {
posts: Post[];
};
const PostList: React.FC<Props> = ({ posts }) => {
return (
<Fragment>
{posts.map(p => (
<PostListItem key={p.id} post={p} />
))}
</Fragment>
);
};
export { PostList };
// PostListItem.tsx
type Props = {
post: Post;
};
const PostListItem: React.FC<Props> = ({ post }) => {
return (
<article className="bg-gray-100 border-gray-400 rounded-lg p-6 m-4 transition duration-300 ease-in-out transform hover:-translate-y-2 ">
<div className="text-center md:text-left">
<span className="text-lg">{post.title}</span>
<p className="text-purple-500">{post.body}</p>
</div>
</article>
);
};
export { PostListItem };
If you try out these snippets, TypeScript will show us an error saying that Cannot find name Post
. Of course we could export
it in index.tsx
, but if we do so we would need to import Post
in every single file we reference it, so a better option would be to make it globally available by moving it to a types.d.ts
file (it should be at the same level as node_modules
)
// types.d.ts
// no `export` required in here
type Post = {
id: number;
title: string;
body: string;
};
type NetworkPost = {
userId: number;
id: number;
title: string;
body: string;
};
Awesome! So far we are fetching data from a API, passing it to our page component, and then we created some components to display our data, using TypeScript to type-check props
It would be cool to be able to click one post, and go to a different page where we could see it in detail, right? For that feature we could think about having a route such as /post/{postId}
. For that matter, Next.js allows us to define dynamic routes, using the same file-based routing system.
Inside pages
directory, create a post
directory. Then, inside our new directory, create a [id].tsx
file (yes, you have to add the square brackets). This way we are telling Next.js that this will be our page to display a specific post whenever we navigate to /post/{postId}
// pages/post/[id].tsx
import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next";
import { Fragment } from "react";
export async function getServerSideProps(context: GetServerSidePropsContext) {
const { id } = context.params;
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);
const { title, body, id: postId } = (await res.json()) as NetworkPost;
const post: Post = { title, body, id: postId };
return {
props: { post }
};
}
export default function PostDetailPage({
post
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<Fragment>
<h1 className="m-4 text-center text-3xl text-red-400">{post.title}</h1>
<p className="m-8">{post.body}</p>
</Fragment>
);
}
We are not doing anything new in this page, the only thing to mention is the context
argument that getServerSideProps
receives, from which we can retrieve the id
parameter from the URL, and then use it to fetch the specific post we want to display.
Having our detail page done, we now have to update PostListItem
component so it renders a link to the corresponding detail page.
// PostListItem.tsx
type Props = {
post: Post;
};
const PostListItem: React.FC<Props> = ({ post }) => {
return (
<a href={`/post/${post.id}`}>
<article className="bg-gray-100 border-gray-400 rounded-lg p-6 m-4 transition duration-300 ease-in-out transform hover:-translate-y-2 ">
<div className="text-center md:text-left">
<span className="text-lg">{post.title}</span>
<p className="text-purple-500">{post.body}</p>
</div>
</article>
</a>
);
};
export { PostListItem };
Now, whenever we click on a post on the index page, we'll navigate to its corresponding detail page.
When we click on a post on the index page, the whole page refreshes and shows us the detail page. This is not what usually happens when we navigate between pages in a React SPA, where navigation is completely made by JavaScript on the client's browser. If you would like to have that exact same behavior in our Next.js app, you would have to update PostListItem
component in the following way
import Link from "next/link";
type Props = {
post: Post;
};
const PostListItem: React.FC<Props> = ({ post }) => {
return (
<Link href="/post/[id]" as={`/post/${post.id}`}>
<a>
<article className="bg-gray-100 border-gray-400 rounded-lg p-6 m-4 transition duration-300 ease-in-out transform hover:-translate-y-2 ">
<div className="text-center md:text-left">
<span className="text-lg">{post.title}</span>
<p className="text-purple-500">{post.body}</p>
</div>
</article>
</a>
</Link>
);
};
export { PostListItem };
As you can see, we are now importing and using the Link
component, provided by Next.js. It needs a somewhat weird syntax to specify where we would like to navigate (I find it weird at least), but using it we have implemented client-side navigation.
What about initial data? Wasn't it fetched on the server? It will still be fetched on the server. Next.js is smart enough to automatically fetch the corresponding data for the page-component whenever we do client-side navigation, so you don't need to worry about that.
Although having client-side navigation gives us that app-like transition between pages and doesn't render once again the whole layout, it has its disadvantages that make choose server-side navigation over it.
- Doesn't preserve scroll position. In this app we are already displaying 100 posts, and if you click the 100th you will navigate to its detail page. Now, if you go back, you will notice that you're on the top of the page, and not where you left off. Maybe there is some workaround to this issue, and if there is I haven't found it yet.
- Loading state. This one isn't that terrible, and could be solved quite easily by displaying an indeterminate progress bar or a spinner whenever we click on a link. The thing is that if you now click on a post on the index page, the app won't give any kind of notice or alert that something is happening, so it feels like it's stuck or not responding.
Having said that, choosing server-side navigation over its client-side counterpart is a trade-off between user experience (I feel like doing server-side navigation provides better UX out-of-the-box, mostly when it comes to loading state) over performance (we are rendering the whole page every single time with server-side navigation).
Purging styles
I said earlier that TailwindCSS provides a lot of CSS classes out-of-the-box. We can remove those that aren't used in our project, but to do so we have to indicate Taildwind someway how to purge our project. In our tailwind.config.js
file add the following files inside the purge
entry
purge: [
"./src/components/*.tsx",
"./src/components/*.js",
"./src/components/**/*.tsx",
"./src/components/**/*.js",
"./src/pages/*.tsx",
"./src/pages/*.js",
"./src/pages/**/*.tsx",
"./src/pages/**/*.js"
],
Deployment
After this somewhat long tutorial, we have built an app which fetches and renders posts from an API, with a basic layout and a detail page for each post. Next and last thing to do for this article would be to deploy our app to the cloud! For that, we are going to dockerize our app and deploy it to Cloud Run, GCP's serverless container platform. Cloud Run imposes three code requirements containers must meet:
- Containers must listen to incoming HTTP request on a port specified by the
PORT
environmental variable - Containers must be stateless, which means they shouldn't rely on saving files locally and retrieving them afterwards, as they can be torn down any time they are not receiving any traffic
- Containers must not execute any background tasks that go beyond the scope of the incoming request, and they should respond in less than 15 minutes before a timeout occurs
Before deploying our app to Cloud Run, we need a GCP project. To create a new one, go to GCP's console and create a new project. We also need to have gcloud
installed in our machine. Once done, we can now proceed to create our Docker image, publish it to a Google Cloud Registry, and create a new service based on our Docker image.
Docker image
We are going to create our Docker image from the official node:alpine
one. Create a Dockerfile
file next to your node_modules
and add the following
FROM node:alpine
WORKDIR /usr/src/app
COPY . .
RUN npm install && npm run build
ENV PORT 8080
CMD ["npm", "start", "--", "--port", "$PORT", "--hostname", "0.0.0.0"]
We also need a .dockerignore
file, so we can tell Docker to ignore certain files
node_modules
npm-debug.log
Dockerfile
README.md
We are indicating Docker to copy our whole project (excluding those files we told it to), install dependencies with npm install
and build our app with npm run build
, and finally start the Next.js server listening to incoming request from all network interfaces, in the port specified by the PORT
environmental app (will use 8080 if not defined).
Before deploying it to GCP, we can try it locally by running
docker build -t my_nextjs_app .
Which will most probably fail because of an error we left behind when we migrated to TypeScript. To fix it, go to _app.tsx
, add import { Fragment } from "react"
and then replace <React.Fragment>
for <Fragment>
. Try running the docker build
command once again, it should work just fine.
Once we have our image, we can run it by doing
docker run --rm --name nextjs_app -e PORT=9999 -p 8080:9999 my-nextjs_app
And then navigate to http://localhost:8080/
. You should see the exact same app than before. Of course you can choose whatever ports you want or have available, in this case I chose port 9999 to use inside the container, and then map it to my local 8080 port.
Great! Our Next.js app is up and running in a Docker container. Next thing we need to do is upload our Docker image to a GCP registry (we are actually using gcloud
to build our image in the cloud, and store it there), and finally deploy it to Cloud Run. Before proceeding, you need your GCP project ID, which can be found in GCP's console
To upload your image, run
gcloud builds submit --tag gcr.io/<YOUR_PROJECT_ID>/my_nextjs_app
And finally, the moment of truth. To create a new service on Cloud Run based on your image, run
gcloud run deploy <YOUR_SERVICE_NAME> --image gcr.io/<YOUR_PROJECT_ID> --platform managed --region us-central1 --allow-unauthenticated
The first time you run the previous command, it should ask you to enable some GCP APIs, and you should be good to go afterwards. You can also choose another GCP region if you like.
We have built and deployed our Next.js app to the cloud! Although cloud providers are not free, GCP's free-tier is quite generous, and won't charge you anything for your service running on Cloud Run if it hasn't have any traffic.
We came a long way, but we're done now! In my next article I will be talking about how can we integrate a Postgres database to our app so we can retrieve our posts from it, and what options do we have for deploying such a database.
Hope you liked it!
📧 lucianoserruya (at) gmail (dot) com