Traavel: plan your next trip in a few clicks!

Traavel: plan your next trip in a few clicks!

Dive into the making of this NextJS web application powered by Amplify

✨ Introduction

Planning a trip? Picture this - no more chaos, no need to juggle tabs or scan endless reviews. Just a few clicks, and you're set. Welcome to Traavel! It's the travel planner that's all about simplicity and convenience. And in this article, we're going behind the scenes.

We'll take a close look at how Traavel is built, from its foundations in NextJS to the powerful engine running it all, AWS Amplify. It's a journey through the lines of code and technologies that power your travels. So, if you're ready, let's hit the road!

⚡️ Main features

Let's take a look at the main features of the application. We'll dig deeper into the technical details later.

👤 Authentication

We understand the importance of security 🔐. That's why Traavel includes a user authentication feature, powered by Cognito, to keep your travel plans and personal information safe.

Using Amplify, this is all you need in terms of code to have a nice signin / signup / forgot-password modal page:

<Authenticator variation="modal" />

⛰️ Trips management

With Traavel, creating, updating and deleting trips is as easy as pie 🥧. Just select the destination, the start and end dates of your trip, and that's it! You can start adding activities to your days, splitting our itinerary into manageable, enjoyable chunks.

The destination input is powered by Google Places API so that you can get suggestions of places in real time.

After creating the trip, Traavel automatically sets a representative image of the city you're traveling to. This is making use of the Unsplash API, and of course you can update this image at any time.

🌍 Interactive map

What would a travel application be without an interactive map 🗺️? The answer - not quite as powerful. That's why we've equipped Traavel with a dynamic, user-friendly interactive map, thanks to the Google Maps API.

This map doesn't just pinpoint locations; it breathes life into your itinerary, providing real-time directions and laying out your activities for each day. So, whether you're navigating the maze-like streets of Venice or hunting down a hidden gem of a restaurant in New York, Traavel's interactive map makes sure you're never lost.

🚀 Try it!

And that's the gist of it! I invite you to try Traavel at this URL: https://main.d1q4622p31lt9z.amplifyapp.com. I'm sure you'll not be disappointed 😆 (and if you are, leave a comment telling me why 🥲).

You can use the following demo credentials if you don't want to register (although I recommend creating a new user):

📹 Demo video

🛠️ Tech stack

Ok now it's time for the good stuff, I know you were waiting for it 😏. Let's break Traavel down piece by piece.

🌐 Frontend

Built on NextJS, a powerful React-based framework, our frontend forms the user interface of Traavel. This is what our users will be interacting with, where trips are planned, itineraries organized, and maps come alive.

NextJS is the star of the show here, providing the flexibility and modularity of React while offering the benefits of server-side rendering for speedy page loads.

Furthermore, NextJS integrates seamlessly with AWS Amplify, creating a smooth developer experience that's hard to beat.

⚙️ Backend

Here is where all the magic happens 🪄. The backend is the invisible power force that propels Traavel, ensuring every feature, every function, works flawlessly. Entirely developed using AWS Amplify, it provides the robustness and scalability that our application needs to serve users around the globe.

Amplify, with its various components, handles a multitude of tasks. It deals with authentication via Amazon Cognito, manages our application's database using DynamoDB, and provides an easy-to-use GraphQL API with AWS AppSync.

Cognito

Cognito powers the user authentication process on Traavel. Whether a user is signing up for the first time or logging back in to tweak their trip plans, Cognito ensures these processes are secure. It provides user management and identity services that are easy to set up and use, relieving us from the heavy lifting of building and maintaining a user authentication system.

Just use the Amplify CLI or Amplify Studio (if you prefer to do it through the UI) to set up the auth configuration. I personally choose the command line interface, it makes me look cooler 😎:

amplify add auth

You'll be asked a series of questions to configure your authentication service. You can choose the default configuration with minimal setup or manually configure settings to better fit your needs. Want to provide sign in with a username, email, or phone number? No problem. Want to include MFA (Multi-Factor Authentication) or add social providers like Google or Facebook? Cognito has got you covered.

Once you're done with the setup, simply push your changes with amplify push and voilà! Your authentication service is ready to protect your users and their data.

AppSync and DynamoDB

AppSync and DynamoDB: two key players in the Traavel team. AppSync handles the GraphQL API, enabling efficient data transfers and ensuring our users can plan their trips. DynamoDB, our chosen NoSQL database, stores all our data, keeping everything swift and scalable.

Simply put, AppSync gets and sends the data, DynamoDB stores it. Together, they ensure smooth and reliable data management for Traavel, allowing for a seamless user experience.

Just run:

amplify add api

Choose the GraphQL API, define your data model in your schema.graphql file and run amplify push again. That's it. DynamoDB will create the proper tables for you and AppSync will create the queries, mutations and subscriptions needed to interact with the data.

The most difficult thing about this is defining the data model, but that's a different article 😜.

I leave here the GraphQL schema in case anyone is interested:

enum ActivityType {
  FLIGHT
  HOTEL
  MUSEUM
  VISIT
  RESTAURANT
  BAR
  CONCERT
  MEETING
  THEATER
  CRUISE
  OTHER
}

type Activity @model @auth(rules: [{ allow: public }]) {
  id: ID! @primaryKey
  dayId: ID! @index(name: "byDay", sortKeyFields: ["startTime"])
  startTime: AWSDateTime!
  endTime: AWSDateTime!
  name: String!
  description: String
  location: Location!
  type: ActivityType!
}

type Day @model @auth(rules: [{ allow: public }]) {
  id: ID! @primaryKey
  tripId: ID! @index(name: "byTrip", sortKeyFields: ["date"])
  date: AWSDateTime!
  activities: [Activity] @hasMany
}

type Location {
  latitude: Float
  longitude: Float
}

type User @model @auth(rules: [{ allow: public }]) {
  id: ID! @primaryKey
  email: AWSEmail!
  name: String
  avatarUrl: AWSURL
  trips: [Trip] @hasMany
}

type Trip @model @auth(rules: [{ allow: public }]) {
  id: ID! @primaryKey
  userId: ID! @index(name: "byUser", sortKeyFields: ["startDate"])
  name: String!
  destination: String!
  location: Location!
  startDate: AWSDateTime!
  endDate: AWSDateTime!
  imgUrl: String
  days: [Day] @hasMany
}

Lambda functions

In Traavel, we've utilized two Lambda functions to handle specific tasks:

  1. User Post-Confirmation Trigger: with Cognito, you can attach lambda functions that will run on certain events, such as user sign-up, confirmation or sign-in. In my case, I wanted to create a user in the "User" table. So I ran amplify add function and pasted the following code to implement this functionality:
/* Amplify Params - DO NOT EDIT
    API_TRAAVELWEBAPP_GRAPHQLAPIIDOUTPUT
    API_TRAAVELWEBAPP_USERTABLE_ARN
    API_TRAAVELWEBAPP_USERTABLE_NAME
    ENV
    REGION
Amplify Params - DO NOT EDIT */

const AWS = require("aws-sdk");
const ddb = new AWS.DynamoDB({ apiVersion: "2012-08-10" });

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */
exports.handler = async (event) => {
  const date = new Date();

  if (event.request.userAttributes.sub) {
    let params = {
      Item: {
        id: { S: event.request.userAttributes.sub },
        __typename: { S: "User" },
        name: { S: event.request.userAttributes.name },
        email: { S: event.request.userAttributes.email },
        createdAt: { S: date.toISOString() },
        updatedAt: { S: date.toISOString() },
        _version: { N: "1" },
        _lastChangedAt: { N: Date.now().toString() },
      },
      TableName: process.env.API_TRAAVELWEBAPP_USERTABLE_NAME,
    };

    // Call DynamoDB
    try {
      await ddb.putItem(params).promise();
      console.log("Success: Everything executed correctly");
    } catch (err) {
      console.log("Error", err);
    }

    return event;
  } else {
    // Nothing to do, the user's email ID is unknown
    console.log("Error: Nothing was written to DynamoDB");
    return event;
  }
};

Amplify makes it easy to integrate your lambda function with the DynamoDB table using environment variables such as API_TRAAVELWEBAPP_USERTABLE_NAME. After that, update your Cognito configuration to assign this trigger by running amplify update auth. Finally, run amplify push again to deploy the changes.

  1. Trip deletion: the Traavel data model is something like this -> one user has many trips; one trip has many days; and one day has many activities. In order to completely delete a trip, we also want to remove all those associated identities. This means that several mutations must be made, and I don't want the user to be waiting for all that logic to be finished so that it can move on. So, my solution was to leverage all that logic to a lambda function that will be executed asynchronously while the user keeps using the app:
/* Amplify Params - DO NOT EDIT
    API_TRAAVELWEBAPP_ACTIVITYTABLE_ARN
    API_TRAAVELWEBAPP_ACTIVITYTABLE_NAME
    API_TRAAVELWEBAPP_DAYTABLE_ARN
    API_TRAAVELWEBAPP_DAYTABLE_NAME
    API_TRAAVELWEBAPP_GRAPHQLAPIIDOUTPUT
    API_TRAAVELWEBAPP_TRIPTABLE_ARN
    API_TRAAVELWEBAPP_TRIPTABLE_NAME
    ENV
    REGION
Amplify Params - DO NOT EDIT */

const AWS = require("aws-sdk");

const client = new AWS.DynamoDB.DocumentClient();

/**
 * @type {import('@types/aws-lambda').APIGatewayProxyHandler}
 */
exports.handler = async (event) => {
  console.log(`EVENT: ${JSON.stringify(event)}`);

  const headers = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Headers": "Content-Type",
    "Access-Control-Allow-Methods": "DELETE, OPTIONS",
  };

  if (event.httpMethod !== "DELETE") {
    return {
      statusCode: 400,
      headers,
      body: "Only DELETE method is allowed",
    };
  }

  try {
    // Fetch the tripId from event query params.
    const { tripId } = event.queryStringParameters;

    // Query to get all Days associated with the Trip.
    const queryDaysParams = {
      TableName: process.env.API_TRAAVELWEBAPP_DAYTABLE_NAME,
      IndexName: "byTrip",
      KeyConditionExpression: "tripId = :tripId",
      ExpressionAttributeValues: { ":tripId": tripId },
      ProjectionExpression: "id",
    };
    const { Items: days } = await client.query(queryDaysParams).promise();
    console.log(`DAYS: ${JSON.stringify(days)}`);

    // For each Day, get all associated Activities
    const activityDeletionPromises = [];
    const dayDeletionPromises = [];
    for (const day of days) {
      const queryActivitiesParams = {
        TableName: process.env.API_TRAAVELWEBAPP_ACTIVITYTABLE_NAME,
        IndexName: "byDay",
        KeyConditionExpression: "dayId = :dayId",
        ExpressionAttributeValues: { ":dayId": day.id },
        ProjectionExpression: "id",
      };

      const { Items: activities } = await client
        .query(queryActivitiesParams)
        .promise();
      console.log(`ACTIVITIES: ${JSON.stringify(activities)}`);

      // Delete activities associated with this day
      for (const activity of activities) {
        const deleteActivityParams = {
          TableName: process.env.API_TRAAVELWEBAPP_ACTIVITYTABLE_NAME,
          Key: { id: activity.id },
        };
        activityDeletionPromises.push(
          client.delete(deleteActivityParams).promise()
        );
      }

      // Delete this day
      const deleteDayParams = {
        TableName: process.env.API_TRAAVELWEBAPP_DAYTABLE_NAME,
        Key: { id: day.id },
      };
      dayDeletionPromises.push(client.delete(deleteDayParams).promise());
    }

    // Delete the Trip
    const deleteTripParams = {
      TableName: process.env.API_TRAAVELWEBAPP_TRIPTABLE_NAME,
      Key: { id: tripId },
    };
    const tripDeletionPromises = [client.delete(deleteTripParams).promise()];

    // Wait for all deletions to complete
    await Promise.all([
      ...activityDeletionPromises,
      ...dayDeletionPromises,
      ...tripDeletionPromises,
    ]);

    return { statusCode: 200, headers, body: "Successfully deleted item" };
  } catch (error) {
    console.log(`Error: ${error}`);
    return { statusCode: 500, headers, error };
  }
};

Amplify Hosting

I want to finally mention a feature that's as important as any other we've discussed: Amplify Hosting. This is what brings Traavel to the world, making our application accessible to travelers everywhere.

Amplify Hosting provides a fast, reliable, and secure environment for our NextJS application. It simplifies the deployment process with features like continuous deployment from your Git repository and custom domain setup (which I've not set up, that's why the URL is so ugly). With just a few clicks, our updates are live and ready for users to enjoy.

😥 Limitations and challenges faced

Ok, let's talk now about the bad things. I'll be honest: implementing Traavel has not been a bed of roses. As with any other tool out there, Amplify has its pros and cons, and I've been struggling at some points of the development process trying to overcome those obstacles.

Storage files without expiration date

One feature that I finally didn't implement is allowing the user to upload their own pictures of the cities they're going to visit. For this, I needed to use the Storage component of Amplify, which sets up an S3 bucket for your users to store files.

Alright, I found out that currently Amplify doesn't allow retrieving those objects without an expiration time. This is, the URL returned by S3 will expire sooner than later, I think the maximum time allowed is 2 weeks. This means that, if a user chose to upload a photo for their trip, in two weeks the URL will be no longer valid, and a new one should be created. As you can imagine, this isn't an ideal scenario for a feature that's supposed to provide a seamless user experience.

It seems that the Amplify team is working on this feature already: https://github.com/aws-amplify/amplify-js/issues/9418, but at the moment we will have to make do with the images that Unsplash offers us.

Cache Unsplash photos URLs

Amplify has a basic cache system that leverages the browser's local storage. I thought this could be useful to store the response from Unsplash so that I don't throttle the API (or raise my bill sky-high).

The problem is that this Amplify caching system does some weird things. When I was switching between trips, sometimes the cache items were being overwritten. Seems like an issue with the Cache component, so I ended up manipulating the local storage directly instead of through Amplify.

DynamoDB batch operations

In a previous section, I talked about the need for deleting the trip and associated days and activities. This could be made more efficient if we leveraged DynamoDB batch operations.

The thing is that AppSync doesn't allow this by default. It can be implemented by defining your own custom mutation, I found this post which does exactly that: https://medium.com/@jan.hesters/creating-graphql-batch-operations-for-aws-amplify-with-appsync-and-cognito-ecee6938e8ee.

But I didn't want to spend so much time on that, so I finally ended up using for loops in my lambda function. Anyway, this process is invisible to the user, so I don't mind being a little inefficient here.

🤔 Conclusion

Embarking on our journey with Amplify to build Traavel has been quite an adventure. As with any travel experience, there have been scenic routes and a few detours. Through it all, it's evident that Amplify is a powerful tool that has enabled us to create an engaging and functional travel planning application.

It simplifies the backend creation process, but this simplicity can become a limitation when trying to implement more complex backend logic. Certain customizations can require a deeper understanding of AWS services than initially expected, and that might involve some head-scratching moments.

Also, as Amplify is a high-level service, it abstracts away many of the nitty-gritty details of underlying services. While this is often a plus, it can sometimes become a hurdle, especially when debugging or trying to perform more specific tasks that require direct interaction with these underlying services.

My experience suggests that Amplify is an excellent choice for simple applications or projects where its built-in functionalities closely align with your needs. For small to medium-sized projects, the time and effort Amplify saves can be a game-changer.

However, for more complex or specialized use cases, the limitations of Amplify might start to surface. You might find yourself spending more time than you'd like working around these constraints or diving deeper into AWS services to get things working.

In these cases, it might be worth considering other options or being prepared to build custom solutions. Yet, even with the bumps in the road, our journey with Amplify has been a fruitful one, and the resulting application, Traavel, has made it all worth it 😃.