This shows you the differences between two versions of the page.
se:labs:05 [2022/11/08 21:38] vlad.stefanescu [Layouts] |
se:labs:05 [2024/11/12 13:07] (current) gabriel.nicolae3103 [Tasks] |
||
---|---|---|---|
Line 1: | Line 1: | ||
- | ====== Lab 05 - Next.js: SSR and SSG ====== | + | ====== Lab 05 - Database ====== |
- | ===== Single Page Applications (SPAs) ===== | + | ===== MongoDB ===== |
- | Classic web pages usually render visual elements on the screen using **HTML** (to structure the visual elements) and **CSS** (to style them), while utilizing JavaScript just to make that page interactive (e.g. trigger an action when the user clicks a button, perform error handling for forms, display additional information on hover, etc.). | + | MongoDB is a popular NoSQL database that stores data in a document-oriented format. Unlike traditional relational databases that store data in tables with rows and columns, MongoDB uses collections of documents. Each document is a flexible, schema-less structure that stores data as key-value pairs, typically in JSON format (BSON in MongoDB). This allows for greater flexibility in data modeling, making it easy to handle a variety of data types. |
- | In contrast, **Single Page Applications** or **SPA**s rely heavily on **JavaScript** for almost everything, including rendering the entire user interface (UI). That is also the case for almost every web development framework (also simply called JavaScript frameworks) out there, including Angular, Vue, and React. **React** is a telling example, as it uses JavaScript for writing every part of the component, including the template (JSX) and the styling (more on that in future tutorials). When you build a React application to make it ready for production, the JSX template, the styling of the components, and the JS logic are transformed using a compiler (usually **Babel**) and bundled together into pure JS code and optimized using a dedicated build tool (usually **Webpack**). The resulting JS resources together with minimal HTML and CSS boilerplate are then served to the browser when the latter performs a GET request. | + | Relational databases, such as MySQL or PostgreSQL, rely on predefined schemas, which means the structure of the data must be fixed and the relationships between entities are explicitly defined. This can make them rigid and less adaptable to changes. In contrast, MongoDB’s document model enables dynamic schemas, which allows for faster development, especially when dealing with complex or evolving data structures. |
- | {{:se:labs:se-react-spa.png?700|Single Page Applications}} | + | As a NoSQL database, MongoDB does not use SQL (Structured Query Language) for querying data. Instead, it uses its own query language that supports powerful, flexible querying capabilities, including searching within documents and aggregating data. This makes MongoDB particularly suited for handling large volumes of unstructured or semi-structured data, such as user-generated content, logs, and real-time analytics. |
- | An **advantage** of this approach is that the server's response to a client request comprises minimal HTML and CSS code and some JavaScript resources that are usually obfuscated and heavily minified. That leads to faster response times from the server and a pleasing user experience (UX), as the visual elements start to be rendered dynamically as soon as the browser starts executing the JavaScript received from the server. Moreover, when the user navigates through the app, a few requests are further sent to the server as the majority of new visual elements are displayed in the browser by executing even more JavaScript written in the same resources that were initially fetched when the page was initialized. | + | Example of a document stored in MongoDB: |
- | The big **disadvantage** however, is that crawlers don't like SPAs. That means that when Google's bots visit your page to index it and then rank it for search results, they usually don't like to wait for the loading and execution of JS code so they can understand what visual elements are in it. And even if the crawlers have become increasingly more performant and JS-tolerant, there is still a problem with semantics. JavaScript frameworks were not designed with semantics in mind. That means that the HTML elements that are dynamically generated at runtime usually don't respect the best practices (e.g. use paragraphs for text content, use headers in hierarchical order, include [[https://www.w3schools.com/html/html5_semantic_elements.asp|semantic elements]], etc.). That is a big red flag for SEO and solving it just by using React is pretty difficult. | + | <code> |
- | + | { | |
- | ===== Pre-Rendering (SSR and SSG) ===== | + | "id": 1, |
- | + | "description": "SE Lab5", | |
- | To solve the discussed issues, we can use **pre-rendering**. That is generating HTML and CSS code in advance, for each page instead of relying solely on the client-side execution of JavaScript. As a result, the amount of JS code executed by the browser is heavily reduced as it is used only for making the page fully interactive. This process is called **hydration** and a great tool for doing this in React is **Next.js**. | + | "completed": false |
- | + | ||
- | {{:se:labs:se-react-prerendering.png?700|}} | + | |
- | + | ||
- | There are usually **2 types of pre-rending**, both being supported by Next.js. | + | |
- | + | ||
- | === 1. Server-Side Rendering (SSR) === | + | |
- | + | ||
- | In this case, the HTML and CSS code is **generated on each request**. This is an SEO-friendly approach that is hybrid in nature, as it allows the application to serve different content in a context-dependent fashion while minimizing the amount of JavaSript code that gets executed on the client. Hence, **SSR** is perfect for scenarios where real-time interactivity (e.g. a live map, live stats display, counter, etc.) is not required, but some parts of the page cannot be determined at build time, but on each HTTP request. | + | |
- | + | ||
- | {{:se:labs:se-next-ssr.png?700|}} | + | |
- | + | ||
- | + | ||
- | === 2. Static Site Generation (SSG) === | + | |
- | + | ||
- | **SSG** goes one step further and pre-renders the styled HTML at build time. That means that the same content will be returned for every subsequent HTTP request and data on that page will not change whatsoever. This approach is suitable for landing pages, articles, blog posts, or other web pages that solely rely on static content. This pre-rendering technique is **highly recommended** as it offers huge SEO benefits, but it can also be leveraged by Content Delivery Networks (CDNs) or other caching techniques (e.g. [[https://www.cloudflare.com/en-gb/learning/cdn/what-is-a-cdn/|Cloudflare]]). | + | |
- | + | ||
- | {{:se:labs:se-next-ssg.png?700|}} | + | |
- | + | ||
- | ===== SSG Implementation ===== | + | |
- | + | ||
- | When generating content at build time, Next.js also supports fetching external data beforehand. That can be performed by defining the **//getStaticProps//** inside the file where a page is defined (more on pages later). | + | |
- | + | ||
- | <code javascript> | + | |
- | export default function Home(props) { | + | |
- | return <p>Little known fact: {props.fact}</p>; | + | |
- | } | + | |
- | + | ||
- | export async function getStaticProps() { | + | |
- | const fact = getAwesomeFactFromApi(); | + | |
- | + | ||
- | // These are the same props that are passed as an argument to the component | + | |
- | return { | + | |
- | props: { fact } | + | |
- | }; | + | |
} | } | ||
</code> | </code> | ||
- | <note important> | + | The advantages of using documents are: |
- | The **//getStaticProps//** is executed on the server side and it is not included in the client bundle. | + | * Documents correspond to native data types in many programming languages. |
- | </note> | + | * Embedded documents and arrays reduce need for expensive joins. |
+ | * Dynamic schema supports fluent polymorphism. | ||
- | ===== SSR Implementation ===== | ||
- | Likewise, external data can be fetched at request time, before bundling the web page and returning it to the client. To do that, the **//getServerSideProps//** function must be defined in the same file where the page is defined (again, more on pages later). | + | ===== MongoDB Operations ===== |
- | <code javascript> | + | We can perform a variety of operations in MongoDB, including inserting, finding, updating, and deleting documents. Additionally, we can perform aggregation queries to process and transform our data. These operations allow us to efficiently manage and manipulate large volumes of data. |
- | export default function UserProfile({ firstName, lastName }) { | + | |
- | return <p>Hi! My name is {firstName} {lastName}.</p>; | + | |
- | } | + | |
- | export async function getServerSideProps() { | + | ==== Find ==== |
- | const userId = ... // get the userId somehow (e.g. from the query param) | + | |
- | + | ||
- | const profileData = fetchUserData(userId); | + | |
- | // These are the same props that are passed as an argument to the component | + | We can use the find() method to retrieve documents from a collection based on specified criteria. This operation allows us to query data with simple or complex conditions. |
- | return { | + | |
- | props: { | + | |
- | firstName: profileData.firstName, | + | |
- | lastName: profileData.lastName, | + | |
- | } | + | |
- | }; | + | |
- | } | + | |
- | </code> | + | |
- | <note important> | + | <code> |
- | The **//getServerSideProps//** is executed on the server side as well and it is not included in the client bundle. However, the server's response time will be higher because the data must be recomputed with each request and hence, it cannot be cached by a CDN. | + | // Find all documents in the collection |
- | </note> | + | db.Todos.find({}) |
- | ===== Client-Side Rendering ===== | + | |
- | Conveniently, if the **//getStaticProps//** and **//getServerSideProps//** are not defined, Next.js will automatically use the SSG approach. However, if further tweaks must be performed exclusively on the client side after the static page is received, that can be done using hooks as one normally does in React. | + | // Find a specific document by its 'id' |
- | + | db.Todos.findOne({ id: 1 }) | |
- | {{:se:labs:se-next-client-side-rendering.png?700|}} | + | |
- | + | ||
- | Next.js recommends fetching data on the client using [[https://swr.vercel.app/|SWR]], a popular React hook just for that. | + | |
- | + | ||
- | <code javascript> | + | |
- | import useSWR from 'swr'; | + | |
- | + | ||
- | function Dashboard() { | + | |
- | // "fetcher" can be Axios | + | |
- | const { dashboardData, error } = useSWR('/api/dashboard/stats', fetcher); | + | |
- | + | ||
- | if (error) return <p>An error occurred!</p>; | + | |
- | if (!dashboardData) return <div>Loading dashboard data...</div>; | + | |
- | return ( | + | |
- | <div> | + | |
- | <h1>My Dashboard</h1> | + | |
- | <p>Visitors: {dashboardData.visitors}</p> | + | |
- | <p>CPU usage: {dashboardData.cpu}%</p> | + | |
- | <p>Memory usage: {dashboardData.memoryMB}MB</p> | + | |
- | </div> | + | |
- | ); | + | |
- | } | + | |
</code> | </code> | ||
- | ===== Next.js Routing ===== | ||
- | Next.js has an opinionated way of handling the application routes. Each //**.js**//, //**.jsx**//, //**.ts**//, or //**.tsx**// file that sits in the **pages** directory represents a page and from a React perspective, it is just another component. However, these are the only files in which the **//getServerSideProps//** and **//getStaticProps//** functions can be written to inform Next.js whether those pages are using SSR or SSG. | + | ==== Insert ==== |
- | By default, each directory represents a part of the resulting URL of your page. That also applies to those files whose names are not **//index//**. Hence, the following structures are equivalent: | + | In MongoDB, we can insert new documents into a collection using the insertOne() or insertMany() methods. These operations add data to our database. |
- | <code javascript> | + | <code> |
- | * pages/index.jsx | + | // Insert a single document |
- | * pages/blog/index.jsx | + | db.Todos.insertOne({ |
- | * pages/blog/first-post/index.jsx | + | id: 2, |
- | </code> | + | description: 'Startup Engineering Lab 5', |
- | + | completed: false | |
- | <code javascript> | + | }) |
- | * pages/index.jsx | + | |
- | * pages/blog/index.jsx | + | |
- | * pages/blog/first-post.jsx | + | |
- | </code> | + | |
- | + | ||
- | And they both generate the URLs: | + | |
- | <code javascript> | + | // Insert multiple documents |
- | * / | + | db.Todos.insertMany([ |
- | * /blog | + | { id: 3, description: 'Milestone 3', completed: false }, |
- | * /blog/first-post | + | { id: 4, description: 'Milestone 4', completed: false } |
+ | ]) | ||
</code> | </code> | ||
- | <note tip>Page components can still make use of other components written in the **components** directory.</note> | + | ==== Update ==== |
- | ===== Dynamic Routes ===== | + | The updateOne() and updateMany() methods allow us to modify existing documents in the collection based on specified criteria. |
- | Next.js also supports **dynamic routes** which are useful when the route depends on a URI parameter. Here are some examples of URLs where this might come in handy: | + | <code> |
+ | // Update a single document | ||
+ | db.Todos.updateOne( | ||
+ | { id: 1 }, | ||
+ | { $set: { completed: true } } | ||
+ | ) | ||
- | <code javascript> | + | // Update multiple documents |
- | * /blog/:post-name | + | db.Todos.updateMany( |
- | * /profile/:user-id | + | { completed: false }, |
- | * /videos/:video-id/statistics | + | { $set: { completed: true } } |
- | * /questions/* | + | ) |
</code> | </code> | ||
- | In these cases, square brackets are used in the naming of files or directories to indicate that the route is dynamic: | ||
- | <code javascript> | + | ==== Delete ==== |
- | * /blog/[post-name].jsx | + | |
- | * /profile/:[user-id].jsx | + | |
- | * /videos/[video-id]/statistics.jsx | + | |
- | * /questions/[...all].jsx | + | |
- | </code> | + | |
- | <note tip>You can find a compelling example of how dynamic route structuring works [[https://github.com/vercel/next.js/tree/canary/examples/dynamic-routing|here]].</note> | + | In MongoDB, we can remove documents from a collection using the deleteOne() or deleteMany() methods. These operations allow us to delete either a single document or multiple documents based on specific criteria. |
- | ===== Page Linking ===== | + | <code> |
- | + | // Delete a single document by its 'id' | |
- | To link pages together, Next.js provides you with a dedicated component called **//Link//**, which works similarly to the classic anchor tag. | + | db.Todos.deleteOne({ id: 1 }) |
- | + | ||
- | <code javascript> | + | |
- | import Link from 'next/link'; | + | |
- | + | ||
- | function Home({ otherBlogPosts }) { | + | |
- | return ( | + | |
- | <ul> | + | |
- | <li> | + | |
- | <Link href="/">Home</Link> | + | |
- | </li> | + | |
- | <li> | + | |
- | <Link href="/blog">Thoughts</Link> | + | |
- | </li> | + | |
- | <li> | + | |
- | <Link href="/blog/first-post">How the story beginned</Link> | + | |
- | </li> | + | |
- | {otherBlogPosts.map((post) => ( | + | |
- | <li key={post.id}> | + | |
- | <Link href={`/blog/${encodeURIComponent(post.path)}`}> | + | |
- | {post.name} | + | |
- | </Link> | + | |
- | </li> | + | |
- | ))} | + | |
- | </ul> | + | |
- | ) | + | |
- | } | + | |
- | export default Home; | + | // Delete all completed todos |
+ | db.Todos.deleteMany({ completed: true }) | ||
</code> | </code> | ||
- | As an alternative, if you want to navigate between pages dynamically, from your JavaScript logic, you can do that with **//useRouter//**. | ||
- | + | ==== Aggregation ==== | |
- | <code javascript> | + | |
- | import { useRouter } from 'next/router'; | + | |
- | function Blog({ completePostpath }) { | + | Aggregation operations allow us to perform complex queries, such as grouping, filtering, and transforming our data. |
- | const router = useRouter(); | + | |
- | const handleClick = () => router.push(completePostpath); // e.g. /blog/first-post | + | |
- | return ( | + | <code> |
- | <div> | + | // Count the number of completed todos |
- | <p>Welcome!</p> | + | db.Todos.aggregate([ |
- | <button onClick={handleClick}>My first blog post</button> | + | { $match: { completed: true } }, |
- | </div> | + | { $count: "completedTodos" } |
- | ) | + | ]) |
- | } | + | |
- | export default Blog; | + | // Group todos by 'completed' status and count them |
+ | db.Todos.aggregate([ | ||
+ | { $group: { _id: "$completed", count: { $sum: 1 } } } | ||
+ | ]) | ||
</code> | </code> | ||
- | <note tip> | + | ===== MongoDB Atlas ===== |
- | Any page contained by a **//Link//** will be prefetched by default (including the corresponding data) if SSG is used. The corresponding data for SSR pages is fetched only when the **//Link//** is clicked. | + | |
- | </note> | + | |
- | ===== Disable SSR/SSG ===== | + | To create our MongoDB database we are going to use Atlas, MongoDB Atlas is an integrated suite of data services centered around a cloud database designed to accelerate and simplify how you build with data, or for short, a database in the cloud. |
- | Next.js also allows you to disable the default behavior of pre-generating the HTML and CSS code at build time by explicitly indicating that you want to dynamically load a certain component on the client side. The rendering of that component will be performed by executing JavaScript code in the browser, just like it happens with a classic SPA. This approach might be useful when you need to access client-side specific libraries (e.g. window). | + | First, we need to create an account on https://cloud.mongodb.com and create a new project, you can name your project however you like. After that we need to create a cluster, make sure you select the free tier (M0), the provider and location doesn't matter for now so you can use the default settings (AWS and Frankfurt). |
- | <code javascript> | + | {{:se:labs:mongodb_cluster.png?700|MongoDB Cluster}} |
- | import dynamic from 'next/dynamic'; | + | |
- | const DynamicHeader = dynamic(() => import('../components/header'), { | + | After that make sure to create your database user. |
- | ssr: false, | + | |
- | }); | + | |
- | function Home() { | + | {{:se:labs:mongodb_username.png?700|MongoDB User}} |
- | return ( | + | |
- | <div> | + | |
- | <DynamicHeader/> | + | |
- | <h1>Welcome to my page!</h1> | + | |
- | </div> | + | |
- | ) | + | |
- | } | + | |
- | + | ||
- | export default Home; | + | |
- | </code> | + | |
- | ===== Layouts ===== | ||
- | If you want to reuse components on multiple pages, **layouts** come in handy. For instance, your app might have a header and a footer on each page: | + | Now that our cluster is ready we need to create our database (and our collection). To do that go to Clusters -> Our Cluster -> Browse Collections -> Add my own data. For database name use "**SE2024**" and for Collection name use "**Todos**", after that click on "Create". |
- | <code javascript> | + | Our next step is to find our connection string (we use this to connect to the database), to find our connection string go to Clusters -> Out Cluster -> Connect -> Drivers and copy paste your connection string. |
- | // layouts/layout.jsx | + | |
- | import Navbar from '..components/navbar'; | + | {{:se:labs:mongodb_connection_string.png?700|MongoDB User}} |
- | import Footer from '..components/footer'; | + | |
- | export default function Layout({ children }) { | + | Make sure to replace <db_password> with the password that you set up earlier. Also, you need to add **SE2024** in your connection string like this |
- | return ( | + | <code> |
- | <> | + | mongodb+srv://<db_username>:<db_password>@cluster0.bpou2.mongodb.net/SE2024?retryWrites=true&w=majority&appName=Cluster0. |
- | <Navbar /> | + | |
- | <main>{children}</main> | + | |
- | <Footer /> | + | |
- | </> | + | |
- | ); | + | |
- | } | + | |
</code> | </code> | ||
- | <code javascript> | + | SE2024 represents our database name, if you chose another name for your database, use that. Your connection string will be different, so don't just copy and paste the connection string from above! |
- | // pages/_app.jsx | + | |
- | + | ||
- | import Layout from '../layouts/layout'; | + | |
- | + | ||
- | export default function Home({ Component, pageProps }) { | + | |
- | return ( | + | |
- | <Layout> | + | |
- | <Component {...pageProps} /> | + | |
- | </Layout> | + | |
- | ); | + | |
- | } | + | |
- | </code> | + | |
- | + | ||
- | Multiple layouts can also be used on a per-page basis. You can find more about that [[https://nextjs.org/docs/basic-features/layouts|here]]. | + | |
- | + | ||
- | <note tip>It is good practice to place all the layouts in a dedicated **layouts** directory, like in the example above.</note> | + | |
- | ===== Next.js Configuration ===== | + | |
- | + | ||
- | There is a plethora of configurations supported by Next.js that can be written in the **next.config.js** file which resides at the root of your project. One can set environment variables, base paths for routes, internationalization, CDN support, path reroutes, etc. You can find more about that [[https://nextjs.org/docs/api-reference/next.config.js/introduction|here]]. | + | |
+ | <note tip> | ||
+ | Your connection string should NOT be made public due to security reasons. You should encrypt and decrypt the connection strings using your own keys. | ||
+ | </note> | ||
===== Tasks ===== | ===== Tasks ===== | ||
<note tip> | <note tip> | ||
- | **Material UI** is a popular component library for React. All the necessary dependencies have already been included in the project so you can make use of the readily available components. You can check out the [[https://mui.com/material-ui/|official docs]] to understand how can you include those components in your code. | + | **Mongoose** is an ODM (Object Data Modeling) library for MongoDB. While you don’t need to use an Object Data Modeling (ODM) or Object Relational Mapping (ORM) tool to have a great experience with MongoDB, some developers prefer them. Many Node.js developers choose to work with Mongoose to help with data modeling, schema enforcement, model validation, and general data manipulation. You can check out the [[https://mongoosejs.com/docs/|official docs]] to understand how can you use Mongoose. |
- | + | ||
- | For this project, we will use the [[https://documenter.getpostman.com/view/1946054/S11HvKSz#1e9464c5-6bef-421e-8f07-1a3523a2d98a|Cat Facts API]]. The GET requests will be performed using [[https://stackoverflow.com/questions/68150039/use-axios-in-getstaticprops-in-next-js|Axios]]. | + | |
</note> | </note> | ||
- | - Download the {{:se:labs:se-lab5-tasks-v2.zip|project archive}} for this lab and run **//npm install//** and **//npm run dev//**. | + | - Download the {{:se:labs:lab5.zip|Lab5 Archive}} for this lab and run **//npm install//** and **//npm run dev//**. |
- | - Link the home page and the ///tasks// page together (the tasks page should be reachable from the home page and vice-versa). | + | - Follow the tutorial to create a MongoDB database using Atlas. |
- | - Create an SSR page at ///cats// that displays 3 random cat facts. Use a dedicated Fact component. | + | - Look through the source code to see how Mongoose is utilised to connect to our database and create our Schema. The files you need to watch for are db/mongoose.ts and model/todo.ts |
- | - Create an SSG page at ///cats/breeds// that displays the first 10 breeds in alphabetic order. Use a dedicated Breed component. | + | - Update the MongoDB connection string in **.env.local** with your own. |
- | - Create a layout for your entire application that adds a header containing a cat logo for all of your pages (you already have the logo in the **public** directory). | + | - Go to Atlas and manually create one document to test that the connection and find query works. To create a document go to Clusters -> Browse Collections -> Select the collection -> Insert document. The document needs to have a **text** (string) and a **completed** (boolean) field. MongoDB will automatically assign an _id field. |
- | - Link the homepage with the ///cats// page using the **//Link//** component and the ///cats/breeds// page using the **//useRouter//** hook. | + | - Go to actions.ts and look at the new getItems() function, if you did everything correctly until now your ToDo list should contain one item (the document that you created earlier). |
- | - Create a page at ///dynamic-tasks// that is identical to the ///tasks// page, but it loads the **//TasksList//** component dynamically, on the client side. | + | - Look at the new createItem() function, by creating a new todo item, a new document should be inserted in our collection. |
- | - Build your application using **//npm run build//** and then start your production-ready application using **//npm run start//**. | + | - Using Mongoose, implement the remaining functions in actions.ts by following the TODOs. |
- | - Open the ///tasks// and ///dynamic-tasks// pages on 2 separate tabs and inspect the page sources for both of them. Discuss your findings with the assistant. | + | |
- | - Create a custom 404 page (see this [[https://nextjs.org/docs/advanced-features/custom-error-page|guide]]). | + | |
- | + | ||
- | <note tip>If you want to create a proper header for your application, [[https://mui.com/material-ui/react-app-bar/|this]] might come in handy.</note> | + | |
- | ===== Feedback ===== | + | ====== Feedback ====== |
- | Please take a minute to fill in the **[[https://forms.gle/NuXCJktudGzf4rLg6 | feedback form]]** for this lab. | + | Please take a minute to fill in the **[[https://forms.gle/PNZYNNZFrVChLjag8 | feedback form]]** for this lab. |