This is a project write-up that was sent to us by Andrew - a superstar freelance developer who discovered our open source tool Preevy and benefited from it in one of his recent projects.
Tell us about what you’re building and how you’re used Preevy - we’d love to share your wisdom with the world!
Check out Preevy and drop us a star ⭐⭐
How I Built a Modern ‘Food Menu’ Web App: From Tech Stack to Workflow
Building successful and efficient web apps needs two things: a strong technological foundation, and a solid approach to managing the work itself.
Many development resources focus on one or the other. There are many good technical resources documenting how to build with vaious technologies and frameworks. There are also many good sources of insight for how to go about managing the development workflow across multiple stakeholders.
But in this article, I will attempt to combine both into one and illustrate how I leveraged some great technologies to not only build a full-stack application, but also to manage the process and the pre-release workflow.
We’ll be looking at how I worked with four main technologies: Node.js, GraphQL, Next.js, and Preevy. Each of these tools has been impactful for the broader web development community, and this is why I thought this was an interesting project and tech stack example to share with the community.
Node.js, for example, provides developers with scalable and efficient backend development tools. Then there’s GraphQL, a cutting-edge data query language that’s changing the way we handle and modify data. Next.js is a sophisticated React framework that aids in the creation of highly performant and SEO-friendly front-end interfaces. Finally, Preevy is a tool for quickly and easily provisioning pre-release preview environments on your cloud (Amazon, Google or Microsoft). Preevy’s shareable preview environments are a useful way for developers to share their latest code changes, get feedback and collaborate with other stakeholders before any code is merged to staging or production, enabling developers to enjoy a faster pre-release review workflow.
Our summary of building a full-stack application will cover the following topics:
- Building the Node.js Express backend server
- Building the Next.js frontend server
- Running the project in Docker
- Provisioning a shareable preview environment using Preevy
Building a “food menu” app in public
I’ve written this summary in the style of a “learning in public” diary. Hopefully this will make the content both interesting and accessible to a wide audience.
The app that we will be building will be for a food menu. Essentially there will be a Node.js backend with a GraphQL server that has an endpoint for our data which will be hard coded. And the Next.js frontend will connect to the Node.js backend to retrieve the data using GraphQL queries.
Prerequisites
Make sure you have the prerequisites now let’s get started!
Building the Node.js Express backend server
We are going to start by creating our project locally on our computer. Navigate to a directory and run the commands below to setup our project and backend structure:
mkdir menu-project
cd menu-project
mkdir server
touch docker-compose.yml
cd server
npm init -y
npm i express express-graphql graphql cors
npm i -D dotenv nodemon
mkdir data schema
touch .env Dockerfile index.js
touch data/menu.js schema/schema.js
Next, open the project in a code editor and then add the upcoming code to the correct files.
Put this code in data/menu.js
:
const bases = [
{
id: '1',
menuItem: 'Base',
name: 'Egg Noodles',
},
{
id: '2',
menuItem: 'Base',
name: 'Whole-wheat Noodles',
},
{
id: '3',
menuItem: 'Base',
name: 'Rice Noodles',
},
{
id: '4',
menuItem: 'Base',
name: 'Udon Noodles',
},
{
id: '5',
menuItem: 'Base',
name: 'Jasmine Rice',
},
{
id: '6',
menuItem: 'Base',
name: 'Whole-grain Rice',
},
];
const vegetables = [
{
id: '1',
menuItem: 'Vegetables',
name: 'Pak Choi',
},
{
id: '2',
menuItem: 'Vegetables',
name: 'Shiitake Mushrooms',
},
{
id: '3',
menuItem: 'Vegetables',
name: 'Champignon Mushrooms',
},
{
id: '4',
menuItem: 'Vegetables',
name: 'Mixed Peppers',
},
{
id: '5',
menuItem: 'Vegetables',
name: 'Broccoli',
},
{
id: '6',
menuItem: 'Vegetables',
name: 'Spinach',
},
{
id: '7',
menuItem: 'Vegetables',
name: 'Baby Corn',
},
{
id: '8',
menuItem: 'Vegetables',
name: 'Red Onion',
},
{
id: '9',
menuItem: 'Vegetables',
name: 'Bamboo Shoots',
},
];
const meats = [
{
id: '1',
menuItem: 'Meat',
name: 'Chicken',
},
{
id: '2',
menuItem: 'Meat',
name: 'Chicken Katsu',
},
{
id: '3',
menuItem: 'Meat',
name: 'Beef',
},
{
id: '4',
menuItem: 'Meat',
name: 'Pulled Beef',
},
{
id: '5',
menuItem: 'Meat',
name: 'Bacon',
},
{
id: '6',
menuItem: 'Meat',
name: 'Pork',
},
{
id: '7',
menuItem: 'Meat',
name: 'Duck',
},
{
id: '8',
menuItem: 'Meat',
name: 'Prawns',
},
{
id: '9',
menuItem: 'Meat',
name: 'Tofu',
},
];
const sauces = [
{
id: '1',
menuItem: 'Sauce',
name: 'Sweet Teriyaki',
},
{
id: '2',
menuItem: 'Sauce',
name: 'Sweet and Sour',
},
{
id: '3',
menuItem: 'Sauce',
name: 'Garlic and black pepper',
},
{
id: '4',
menuItem: 'Sauce',
name: 'Oyster Sauce',
},
{
id: '5',
menuItem: 'Sauce',
name: 'Hot soybean sauce',
},
{
id: '6',
menuItem: 'Sauce',
name: 'Yellow curry & coconut',
},
{
id: '7',
menuItem: 'Sauce',
name: 'Peanut',
},
{
id: '8',
menuItem: 'Sauce',
name: 'Asian spiced red sauce',
},
];
module.exports = { bases, vegetables, meats, sauces };
This code will go into schema/schema.js
:
const { bases, vegetables, meats, sauces } = require('../data/menu');
const {
GraphQLObjectType,
GraphQLID,
GraphQLString,
GraphQLList,
GraphQLSchema,
} = require('graphql');
const BaseType = new GraphQLObjectType({
name: 'Base',
fields: () => ({
id: { type: GraphQLID },
menuItem: { type: GraphQLString },
name: { type: GraphQLString },
}),
});
const VegetableType = new GraphQLObjectType({
name: 'Vegetable',
fields: () => ({
id: { type: GraphQLID },
menuItem: { type: GraphQLString },
name: { type: GraphQLString },
}),
});
const MeatType = new GraphQLObjectType({
name: 'Meat',
fields: () => ({
id: { type: GraphQLID },
menuItem: { type: GraphQLString },
name: { type: GraphQLString },
}),
});
const SauceType = new GraphQLObjectType({
name: 'Sauce',
fields: () => ({
id: { type: GraphQLID },
menuItem: { type: GraphQLString },
name: { type: GraphQLString },
}),
});
const RootQuery = new GraphQLObjectType({
name: 'RootQueryType',
fields: {
bases: {
type: new GraphQLList(BaseType),
resolve(parent, args) {
return bases;
},
},
base: {
type: BaseType,
args: { id: { type: GraphQLID } },
resolve(parent, args) {
return bases.find((base) => base.id === args.id);
},
},
vegetables: {
type: new GraphQLList(VegetableType),
resolve(parent, args) {
return vegetables;
},
},
vegetable: {
type: VegetableType,
args: { id: { type: GraphQLID } },
resolve(parent, args) {
return vegetables.find((vegetable) => vegetable.id === args.id);
},
},
meats: {
type: new GraphQLList(MeatType),
resolve(parent, args) {
return meats;
},
},
meat: {
type: MeatType,
args: { id: { type: GraphQLID } },
resolve(parent, args) {
return meats.find((meat) => meat.id === args.id);
},
},
sauces: {
type: new GraphQLList(SauceType),
resolve(parent, args) {
return sauces;
},
},
sauce: {
type: SauceType,
args: { id: { type: GraphQLID } },
resolve(parent, args) {
return sauces.find((sauce) => sauce.id === args.id);
},
},
},
});
module.exports = new GraphQLSchema({
query: RootQuery,
});
And here we have our .env
file:
NODE_ENV = "development"
PORT = 8080
Let’s put this code in the Dockerfile
:
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8080
CMD ["node", "index.js"]
And our index.js
file gets this server code:
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const { graphqlHTTP } = require('express-graphql');
const schema = require('./schema/schema');
const app = express();
app.use(cors());
app.use(
'/graphql',
graphqlHTTP({
schema,
graphiql: process.env.NODE_ENV === 'development',
})
);
const port = process.env.PORT || 8080;
app.listen(port, () =>
console.log(`Server running on port ${port}, http://localhost:${port}`)
);
And lastly the docker-compose.yml
file:
version: '3'
services:
server:
container_name: server
build:
context: ./server
dockerfile: Dockerfile
volumes:
- ./server:/app
ports:
- '8080:8080'
environment:
- NODE_ENV=development
client:
container_name: client
build:
context: ./client
dockerfile: Dockerfile
volumes:
- ./client/src:/app/src
- ./client/public:/app/public
restart: always
ports:
- 3000:3000
We just have to add these run scripts to the package.json
file and we are good to go:]
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
},
Now with the backend part of our project setup just go into the root folder for the server and run the command below to start the backend server:
npm run start
Just go to http://localhost:8080/graphql to see your GraphQL API.
Next up is the front end let’s get to it.
Building the Next.js frontend server
Change your directory so that it is in the root of the menu-project
folder and run the commands below to set up our project to use Next.js.
npx create-next-app client
Complete the setup I used this configuration here:
✔ Would you like to use TypeScript with this project? … No / Yes
✔ Would you like to use ESLint with this project? … No / Yes
✔ Would you like to use Tailwind CSS with this project? … No / Yes
✔ Would you like to use src/
directory with this project? … No / Yes
✔ Use App Router (recommended)? … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
cd
into the client
folder and run this command to install the packages we will need:
npm i @apollo/client graphql
We need to create project files now so let’s run this code to get them done:
touch Dockerfile
cd src/app
mkdir components queries utils
touch components/Bases.js components/Meats.js components/Sauces.js components/Vegetables.js
touch queries/clientQueries.js
touch utils/withApollo.js
All that’s left is to add the code to our files and we are done. So starting with components/Bases.js
:
'use client';
import { useQuery } from '@apollo/client';
import { GET_BASE } from '../queries/clientQueries';
import withApollo from '../utils/withApollo';
const Bases = () => {
const { loading, error, data } = useQuery(GET_BASE);
if (loading) return <p>Loading bases...</p>;
if (error) return <p>The food failed to load there is a problem</p>;
return (
<div>
{!loading && !error && (
<div className="base-box">
<div>
<div className="cost-container">
<h1>01</h1>
<p>$5.95 only one</p>
</div>
<h2>
Chose <br />
your base
</h2>
{data.bases.map((bases) => (
<div key={bases.id}>
<table>
<tr>
<td>
{bases.id} {bases.name}
</td>
</tr>
</table>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default withApollo(Bases);
Next up is components/Meats.js
:
'use client';
import { useQuery } from '@apollo/client';
import { GET_MEAT } from '../queries/clientQueries';
import withApollo from '../utils/withApollo';
const Meats = () => {
const { loading, error, data } = useQuery(GET_MEAT);
if (loading) return <p>Loading meats...</p>;
if (error) return <p>The food failed to load there is a problem</p>;
return (
<div>
{!loading && !error && (
<div className="meat-box">
<div>
<div className="cost-container">
<h1>03</h1>
<p>$1.25 each 2 Max</p>
</div>
<h2>
Choose <br /> your Meats
</h2>
{data.meats.map((meats) => (
<div key={meats.id}>
<table>
<tr>
<td>
{meats.id} {meats.name}
</td>
</tr>
</table>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default withApollo(Meats);
Following that add this code to components/Sauces.js
:
'use client';
import { useQuery } from '@apollo/client';
import { GET_SAUCE } from '../queries/clientQueries';
import withApollo from '../utils/withApollo';
const Sauces = () => {
const { loading, error, data } = useQuery(GET_SAUCE);
if (loading) return <p>Loading sauces...</p>;
if (error) return <p>The food failed to load there is a problem</p>;
return (
<div>
{!loading && !error && (
<div className="sauces-box">
<div>
<div className="cost-container">
<h1>04</h1>
<p>FREE</p>
</div>
<h2>
Choose <br />
your Sauces
</h2>
{data.sauces.map((sauces) => (
<div key={sauces.id}>
<table>
<tr>
<td>
{sauces.id} {sauces.name}
</td>
</tr>
</table>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default withApollo(Sauces);
This code will be going into components/Vegetables.js
:
'use client';
import { useQuery } from '@apollo/client';
import { GET_VEGETABLE } from '../queries/clientQueries';
import withApollo from '../utils/withApollo';
const Vegetables = () => {
const { loading, error, data } = useQuery(GET_VEGETABLE);
if (loading) return <p>Loading vegetables...</p>;
if (error) return <p>The food failed to load there is a problem</p>;
return (
<div>
{!loading && !error && (
<div className="vegetable-box">
<div>
<div className="cost-container">
<h1>02</h1>
<p>$1.25 each 4 Max</p>
</div>
<h2>
Choose <br />
your Vegetables
</h2>
{data.vegetables.map((vegetables) => (
<div key={vegetables.id}>
<table>
<tr>
<td>
{vegetables.id} {vegetables.name}
</td>
</tr>
</table>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default withApollo(Vegetables);
Now over to the queries/clientQueries.js
:
import { gql } from '@apollo/client';
const GET_BASE = gql`
query getBase {
bases {
id
name
menuItem
}
}
`;
const GET_VEGETABLE = gql`
query getVegetable {
vegetables {
id
name
menuItem
}
}
`;
const GET_MEAT = gql`
query getMeat {
meats {
id
name
menuItem
}
}
`;
const GET_SAUCE = gql`
query getSauce {
sauces {
id
name
menuItem
}
}
`;
export { GET_BASE, GET_VEGETABLE, GET_MEAT, GET_SAUCE };
Almost done this code is for utils/withApollo.js
:
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import { useMemo } from 'react';
export function initializeApollo(initialState = null) {
const _apolloClient = new ApolloClient({
// Local GraphQL Endpoint
uri: 'http://localhost:8080/graphql',
// Add your Preevy GraphQL Endpoint
// uri: 'https://your-backend-server-livecycle.run/graphql',
cache: new InMemoryCache().restore(initialState || {}),
});
return _apolloClient;
}
export function useApollo(initialState) {
const store = useMemo(() => initializeApollo(initialState), [initialState]);
return store;
}
export default function withApollo(PageComponent) {
const WithApollo = ({ apolloClient, apolloState, ...pageProps }) => {
const client = useApollo(apolloState);
return (
<ApolloProvider client={client}>
<PageComponent {...pageProps} />
</ApolloProvider>
);
};
// On the server
if (typeof window === 'undefined') {
WithApollo.getInitialProps = async (ctx) => {
const apolloClient = initializeApollo();
let pageProps = {};
if (PageComponent.getInitialProps) {
pageProps = await PageComponent.getInitialProps(ctx);
}
if (ctx.res && ctx.res.finished) {
// When redirecting, the response is finished.
// No point in continuing to render
return pageProps;
}
const apolloState = apolloClient.cache.extract();
return {
...pageProps,
apolloState,
};
};
}
return WithApollo;
}
Next up our CSS in globals.css
so replace all the code with this one:
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
}
header h1 {
text-align: center;
color: #ffffff;
font-size: 4rem;
text-transform: uppercase;
}
h1 {
color: #1c1917;
}
h2 {
color: #1c1917;
font-size: 1.4rem;
text-transform: uppercase;
border-top: 0.3rem solid black;
border-bottom: 0.3rem solid black;
margin-bottom: 1rem;
padding: 1rem 0 1rem 0;
}
body {
background: #374151;
}
.container {
width: 100%;
max-width: 90rem;
margin: 2rem auto;
display: flex;
flex-flow: row wrap;
justify-content: space-around;
background: #f9fafb;
padding: 2rem;
}
.base-box,
.vegetable-box,
.meat-box,
.sauces-box {
background: #f43f5e;
width: 20rem;
height: auto;
padding: 1rem;
color: #ffffff;
font-weight: bold;
margin-bottom: 2rem;
}
.cost-container {
display: flex;
flex-flow: row nowrap;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.cost-container p {
background: #eff6ff;
padding: 0.2rem;
color: #f43f5e;
font-size: 1rem;
}
@media screen and (max-width: 800px) {
.container {
flex-flow: column;
align-items: center;
}
}
Just one more to go after this, so next is the page.js
file and like before replace all the code with what we have here:
'use client';
import Bases from './components/Bases';
import Vegetables from './components/Vegetables';
import Meats from './components/Meats';
import Sauces from './components/Sauces';
export default function Home() {
return (
<>
<header>
<h1>Menu</h1>
</header>
<div className="container">
<Bases />
<Vegetables />
<Meats />
<Sauces />
</div>
</>
);
}
Finally, let’s complete our project by putting this code in the Dockerfile
in the client folder:
FROM node:18-alpine
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i; \
# Allow install without lockfile, so example works even without Node.js installed locally
else echo "Warning: Lockfile not found. It is recommended to commit lockfiles to version control." && yarn install; \
fi
COPY src ./src
COPY public ./public
COPY next.config.js .
# Next.js collects completely anonymous telemetry data about general usage. Learn more here: https://nextjs.org/telemetry
# Uncomment the following line to disable telemetry at run time
# ENV NEXT_TELEMETRY_DISABLED 1
# Note: Don't expose ports here, Compose will handle that for us
# Start Next.js in development mode based on the preferred package manager
CMD \
if [ -f yarn.lock ]; then yarn dev; \
elif [ -f package-lock.json ]; then npm run dev; \
elif [ -f pnpm-lock.yaml ]; then pnpm dev; \
else yarn dev; \
fi
Now with the frontend and backend side of our project setup just go into the root folder for the client
and run the command below to start the frontend server:
npm run dev
Double-check that your backend server is still running and you should see the menu which is receiving data from the backend server.
The next section will show us how to run the app inside of a Docker container so on we go.
Running the project in Docker
Getting your application to run in Docker is super easy. First, make sure that Docker is running on your computer and that the other servers are not running because they will use the same ports. Then just ensure that you are inside the root folder for menu-project
and then run the command docker-compose up
. This will run the backend and frontend servers at the same time. Now just got to the same URLs as before for the server and client to see everything working.
The server is running on http://localhost:8080 The client is running on http://localhost:3000
Lastly, its time to deploy a preview environment using Preevy. By doing this we can share our work very easily with other people working on the project. They’ll be able to see the latest version of the app with a single click, without needing to see how it looks or behaves in our development environment. This has already saved me hours of back and forth and I’m very happy to have found this tool and included it in my workflow.
Provisioning a preview environment using Preevy
First, read the documentation on Preevy and install it and then get ready to run the commands here to create a provisioning environment. Inside the root folder for menu-project
run the following commands:
preevy init
preevy up --id 321
You must pass an --id
flag with a string of numbers. I used 321 as an example which should work. The setup might take some time to complete because it has to create the backend and the front end on AWS. When it’s complete you should get two URL’s one for the server and one for the client see the example here:
server 8080 https://your-url-server.livecycle.run/
client 3000 https://your-url-client.livecycle.run/
Clicking on the links should take you to both servers running online. You will notice that the page is showing errors and not loading our data on the client link. That’s because it’s still set to http://localhost:8080/graphql
in your code. Update the uri
in the file client/src/app/utils/withApollo.js
to your Preevy server GraphQL endpoint and then run the command preevy up --id 321
again to push the latest changes.
Now the page should be showing data from our GraphQL API.
Conclusion
Ultimately, the combination of tools like Node.js, GraphQL, Next.js is transforming the online development environment by bringing improved performance, flexibility, and data privacy compliance to the forefront. When these technologies are combined, they constitute a strong toolbox that enables developers to create scalable, efficient, and privacy-conscious apps.
And tools like Preevy facilitate an only better developer experience by enabling developers lightning fast ways to securely deploy and share their work with others, so they can collect clear feedback and release higher-quality products.
Hopefully, you’ve found that this article presents a realistic strategy for exploiting the synergy of these technologies.
However, because technology is always evolving, it is obligatory for every developer to constantly research and experiment with these tools. With this knowledge, you’ll be well-equipped to take your web development talents to the next level, keeping on the cutting edge of technical innovation and creating apps that truly make a difference.