This is the first part of a series where we will build a full-stack app, including the frontend using Vite with React and TypeScript, the backend using Spring Boot with a PostgreSQL database, and we will deploy the application on Render.
Intro
First, let's create a directory called fullstackapp
. Inside it, create two directories: frontend
and backend
. Try to follow the structure below.
Project Structure
.
├── frontend/ # React frontend
│ ├── public/
│ ├── src/ # React components and pages
│ ├── Dockerfile # Dockerfile for React
│ └── package.json # React dependencies
├── backend/ # Spring Boot backend
│ ├── src/ # Java code for APIs and services
│ ├── Dockerfile # Dockerfile for Spring Boot
│ └── pom.xml # Maven dependencies
├── docker-compose.yaml # Docker Compose file to manage multi-container application
└── README.md # Project documentation
Then inside of frontend
directory we are going to create our Vite app.
cd frontend
npm create vite@latest . -- --template react-ts
This will initialize a simple app with React using TypeScript.
We are going to build a simple app, just a text area where you can write messages, and those messages will be displayed in a list on the screen. Some people call this a to-do app, and maybe they are right, but the objective of this article is to show how easy it is to build an app and deploy it to showcase or add to your portfolio.
Think of this as just a starting point; you can create a lot of things from here.
Code
Inside the src
directory, we are going to create a directory called components
and inside of that create three components:
1. Message List
MessageList.tsx
import { MessageListDto } from "../dto/MessageListDto";
const MessageList = ({ messages, eraseFunct }: MessageListDto) => {
return (
<section className="messageSection">
<h1>Messages</h1>
<ul>
{messages.map((obj) => (
<li key={obj.id}>
{obj.message}{" "}
<button
onClick={() => {
eraseFunct(obj.id);
}}
>
Erase Message
</button>
</li>
))}
</ul>
</section>
);
};
export { MessageList };
2. Message Form
MessageForm.tsx
import { MessageFormDto } from "../dto/MessageFormDto";
const MessageForm = ({
submitMessage,
userMessage,
writingMessage,
}: MessageFormDto) => {
return (
<section className="writeMessageSection">
<h1>Write a message</h1>
<form onSubmit={(event) => submitMessage(event)}>
<textarea
onChange={(event) => writingMessage(event.target.value)}
value={userMessage}
/>
<button type="submit" disabled={userMessage === ""}>
Send message !
</button>
</form>
</section>
);
};
export { MessageForm };
3. Message View Model
I recommend you to create a directory inside src
called hooks
and put this file inside of that.
useMessageViewModel.tsx
import { useEffect, useState } from "react";
import { MessageDto } from "../dto/MessageDto";
const useMessageViewModel = () => {
const [messages, setMessages] = useState<MessageDto[]>([]);
const [userMessage, setUserMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getMessagesUrl = import.meta.env.VITE_GET_MESSAGES_URL;
const postMessagesUrl = import.meta.env.VITE_POST_MESSAGES_URL;
const deleteMessageUrl = import.meta.env.VITE_DELETE_MESSAGES_URL;
const loadMessages = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(getMessagesUrl);
const data = await response.json();
setMessages(data);
} catch (err) {
setError(`Failed to load messages due to : ${err}`);
} finally {
setLoading(false);
}
};
const submitMessage = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
try {
const response = await fetch(postMessagesUrl, {
method: "POST",
body: JSON.stringify({ message: userMessage }),
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to send message.");
}
setUserMessage("");
await loadMessages();
} catch (err) {
setError("Error sending message.");
}
};
const deleteMessage = async (id: String) => {
await fetch(`${deleteMessageUrl}/${id}`, { method: "DELETE" });
await loadMessages();
};
useEffect(() => {
loadMessages();
}, []);
return {
messages,
userMessage,
setUserMessage,
submitMessage,
deleteMessage,
loading,
error,
};
};
export { useMessageViewModel };
Explanation
const getMessagesUrl = import.meta.env.VITE_GET_MESSAGES_URL;
const postMessagesUrl = import.meta.env.VITE_POST_MESSAGES_URL;
const deleteMessageUrl = import.meta.env.VITE_DELETE_MESSAGES_URL;
These are environment variables, where you are passing the endpoints/URL from your backend.
You need to follow the prefix syntax for Vite to recognize the environment variables. Example: VITE_YOUR_VARIABLE
Dto directory
Inside the src
directory, we are going to create a directory called dto
and inside of that create three components:
1. Message Dto
MessageDto.ts
type MessageDto = {
message: string;
id: string;
};
export type { MessageDto };
2. Message Form Dto
MessageFormDto.ts
type MessageFormDto = {
submitMessage: (event: React.FormEvent) => Promise<void>;
userMessage: string;
writingMessage: (text: string) => void;
};
export type { MessageFormDto };
3. Message List Dto
MessageListDto.ts
import { MessageDto } from "./MessageDto";
type MessageListDto = {
messages: MessageDto[];
eraseFunct: (id: String) => void;
};
export type { MessageListDto };
CSS
"We are not going to create complex CSS for this app, as the main objective of the article is to show how to deploy the app.
So, clear the App.css
file and write this code:"
*{
box-sizing: border-box;
margin: 0;
padding: 0;
}
.main{
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100vw;
height: 100vh;
}
.messageSection{
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.messageSection > ul{
display:flex;
flex-direction: column;
}
.writeMessageSection{
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.writeMessageSection > form {
display: flex;
flex-direction: column;
align-items: center;
justify-items: center;
}
.writeMessageSection > form > textarea{
resize: none;
width: 400px;
height: 100px;
margin: 20px 0px 20px 0px;
}
App.tsx
Change the App.tsx
file, created by vite, to this code below:
App.tsx
import "./App.css";
import { MessageForm } from "./components/MessageForm";
import { MessageList } from "./components/MessageList";
import { useMessageViewModel } from "./components/useMessageViewModel";
function App() {
const {
messages,
setUserMessage,
submitMessage,
deleteMessage,
userMessage,
error,
loading,
} = useMessageViewModel();
return (
<>
<main className="main">
{loading ? (
<p>Loading...</p>
) : (
<MessageList messages={messages} eraseFunct={deleteMessage} />
)}
{error && <p>{error}</p>}
<MessageForm
writingMessage={setUserMessage}
submitMessage={submitMessage}
userMessage={userMessage}
/>
</main>
</>
);
}
export default App;
Dockerfile
Create a Dockerfile on the root of the frontend
directory, this file will help us on the deploy.
FROM node:20
WORKDIR /app
ARG VITE_GET_MESSAGES_URL
ARG VITE_POST_MESSAGES_URL
ARG VITE_DELETE_MESSAGES_URL
COPY ./package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE "9090"
CMD [ "npm", "run", "preview", "--", "--port", "9090", "--host", "0.0.0.0" ]
Explanation
ARG VITE_GET_MESSAGES_URL
ARG VITE_POST_MESSAGES_URL
ARG VITE_DELETE_MESSAGES_URL
"We need to create the environment variables this way because Vite only injects them during build, not at runtime.
When deploying this application, we need to set these environment variables in the Render panel. However, if you are using another platform, this code will work as well."
CMD [ "npm", "run", "preview", "--", "--port", "9090", "--host", "0.0.0.0" ]
- npm run preview: runs a preview version of the app after the build.
- port: the port you set to run the application.
- host 0.0.0.0: make the application accessible outside the container.
Conclusion
Okay, with this, the frontend is ready for deployment. In the next article, we will build the backend using Spring Boot.
Thanks for reading !!
Top comments (0)