Web Development with Quarkus and Next.js

by Bautista Bambozzi — 60 min

An image of the Quarkus and Next.js logo

In this article, we're going to go over the basics regarding Quarkus and Next.js, and evaluating their developer experience and ergonomics by creating a Full-Stack sample project. We're going to examine Quarkus' dev server, REST and persistence capabilities when interacting with PostgreSQL. We're also superficially checking out Next.js' new Server Actions and file-based routing. To do this, we're going to need the following skills & software:

      A) Have intermediate React & Java knowledege.
      D) Have Node installed.
      E) Have jq installed (optional).

Creating the Quarkus backend

We're going to start by bootstrapping the quarkus app by running the command

terminal


# Create a Quarkus app and add the PostgresSQL driver and the Hibernate ORM.
quarkus create app com.acme:cutebooks-backend:1.0 && cd cutebooks-backend && quarkus ext add io.quarkus:quarkus-jdbc-postgresql io.quarkus:quarkus-hibernate-orm-panache io.quarkus:quarkus-hibernate-validator io.quarkus:quarkus-rest-jackson

The quarkus create app command is going to scaffold our application and build a basic skeleton. Delete the GreetingResource placeholder and start the quarkus dev server by running quarkus dev. Take note that the quarkus dev command will set up a development server that will auto-refresh when a file changes. It'll also set up a real PostgreSQL database in a container to interact with our application during development (no need for H2!). For this particular project, we're keeping it simple so we'll structure the project like this:

project structure


├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/
│   │   │       └── acme/
│   │   │               ├── controller/
│   │   │               │   └── BookController.java    # REST controller
│   │   │               ├── entity/
│   │   │               │   └── Book.java              # Entity with Active Record
│   │   ├── resources/
│   │   │   └── application.properties                 # Configuration file

Let's create our Book! Inside entity/book.java, we're going to try out Panache using the Active Record pattern to persist our entities. We could also use the more classical Repository pattern here, but we're going to try out the paradigm proposed by the Quarkus team. Inside entity/Book.java:

entity/Book.java


@Entity
@Table(name = "books")
public class Book extends PanacheEntityBase {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    @NotBlank
    private String title;
    @Column
    @NotBlank
    private String author;
    @Column
    private int likes = 0;
    public Book(String title, String author, int likes) {
        this.title = title;
        this.author = author;
        this.likes = likes;
    }
    public Book() {
    }

    // Omitted getters, setters and toString
}

We've got the Active Record set up! Once you've set up the properties, as well as the getters and setters, we'll be able to interact with our entities and mutate them as needed. The @Entity is ready to go, but we've yet to provide some data for our database. To fix this, we'll head to src/main/resources/import.sql and seed the database with the following data. Inside main/resources/application.properties, we're setting up the following:

resources/application.properties


quarkus.devservices.enabled=true ## Make Quarkus create a container for us!
quarkus.hibernate-orm.database.generation=drop-and-create # Re-create database on init. For testing purposes only.
quarkus.datasource.db-kind=postgresql # Specify the db-kind
quarkus.hibernate-orm.sql-load-script=load-data.sql # Point quarkus to our load data script
quarkus.http.port=8080 # Set the port

And we'll seed our database with the following books to get started 🐘!

resources/load-data.sql


INSERT INTO books (title, author, likes)
VALUES ('The Aleph', 'Jorge Luis Borges', 100),
       ('Don Quixote', 'Miguel de Cervantes', 110),
       ('The Lord of the Rings', 'J.R.R. Tolkien', 150),
       ('Crime and Punishment', 'Fyodor Dostoevsky', 40),
       ('Frankenstein', 'Mary Shelley', 105),
       ('One Hundred Years of Solitude', 'Gabriel García Márquez', 80),
       ('The Great Gatsby', 'F. Scott Fitzgerald', 90),
       ('In Search of Lost Time', 'Marcel Proust', 60),
       ('War and Peace', 'Leo Tolstoy', 130),
       ('Moby Dick', 'Herman Melville', 95);

We've already done half the work! Our @Entity is set up, and our database is now correctly seeded and ready to store more data! We'll proceed to create the tried and true Controller, where we'll implement Creating, Reading, Updating and Deleting entries from our database. Firstly, define the controller and add the relevant annotations to describe their role. We'll be using the battle-hardened Jakarta EE annotations. You can describe the HTTP Methods with @GET, @POST, etc. Additionally, you can specify the Content-Type with the @Produces and @Consumes annotation. We're also able to extract the path parameter by using the @PathParam annotation.

controller/BookController.java


@Path("/books")
public class BookController {

    @GET
    @Path("/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getBookById(@PathParam("id") String id) {
        Optional<Book> optionalBook = Book.findByIdOptional(id);
        Book book = optionalBook.orElseThrow(() -> new NotFoundException("Book not found"));
        return Response.ok(book.build();
    };
};

We can then query the endpoint with curl and jq and we should see the following result

terminal


    curl localhost:8080/books/1 | jq


# {
#   "id": 1,
#   "title": "The Aleph",
#   "author": "Jorge Luis Borges",
#   "likes": 100
# }

After we've set up the endpoint to get a specific book, we're going to create one to list all books. It is not much more different than our previous endpoint. Our entity also contains the Book.listAll() method provided by Active Record, which we'll then include in our JSON response within the .entity() method in the builder.

controller/BookController.java


@GET
@Produces(MediaType.APPLICATION_JSON)
public Response allBooks() {
    return Response
            .status(Response.Status.OK)
            .entity(Book.listAll())
            .build();

We're now going to create an endpoint to POST and create a new Book entry. Since we're not doing a safe or idempotent operation, we'd like to rollback changes if we encounter an exception, so we'll add the @Transactional annotation. We're also logically consuming and returning JSON in this endpoint, so we annotate it accordingly. We'd also like the JSON request to be mapped and validated to a familiar Java Object, so we'll add the @Valid annotation to make sure we're getting valid data into our endpoint. Since we're using the Active Record the pattern, we can use the static persist() method from the @Entity class.

controller/BookController.java


    @POST
    @Transactional
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response createBook(@Valid Book book) {
        Book.persist(book);
        return Response.status(Response.Status.CREATED).entity(book).build();
    }

Note how this new Active Record pattern invites us to persist the data via a static method coming from the Book entity. We'll now add a new book to try out the endpoint and see if it works. We're going to run the following command and see if it persists:

terminal


    curl --json '{"title": "Les Chants de Maldoror","author": "Comte de Lautréamont","likes": 0}' \
     localhost:8080/books \
     | jq
#  {
#    "id": 11,
#    "title": "Les Chants de Maldoror",
#    "author": "Comte de Lautréamont",
#    "likes": 0
#  }

Looks good! We should now add the @Delete operation. Since we've seen all these annotations before, nothing will seem odd to us. You can quickly check the endpoint by using curl and using the endpoints we've set up previously.

controller/BookController.java


    @DELETE
    @Transactional
    @Path("/{id}")
    public Response deleteBookById(@PathParam("id") String id) {
        Book.deleteById(id);
        return Response.noContent().build();
    }

Let us now go onto a more interesting feature of the Active Record entities: mutation. Unlike classical JPA repositories where we're expected to .save() the entity after we've modified them, in Active Record just setting the property to a new value via a setter will suffice.

controller/BookController.java



    @PUT
    @Transactional
    @Path("/{id}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    public Response updateBook(@PathParam("id") String id, @Valid Book newBook) {
        Optional<Book> optBook = Book.findByIdOptional(id);
        Book book = optBook.orElseThrow(() -> new NotFoundException("Book not found"));
        book.setAuthor(newBook.getAuthor());
        book.setTitle(newBook.getTitle());
        book.setLikes(newBook.getLikes());
        return Response.ok(book).build();
    }

Notice how simply setting a new value on the Book entity will automatically update on our database! We can then PUT new values to the endpoint to replace them. Make sure to include the id in the request. We'll create a PUT request to change the values of the entity we've POSTed previously. Given our previous entry, we'll send the POST request like so:

terminal


curl -XPUT localhost:8080/books/11 \
-H "Content-Type: application/json" \
-d '{"title": "Les Chants de Maldoror","author": "Isidore Lucien Ducasse","likes": 0}' \
 | jq
# {
#   "id": 11,
#   "title": "Les Chants de Maldoror",
#   "author": "Isidore Lucien Ducasse",
#   "likes": 0
# }

We've finished implementing all four basic CRUD operations, but we're going for gold. We're going to add an endpoint to allow users to like a book. We'll use this later from our Next.js frontend, allowing arbitrary users to like a book and increase the like count by one.

controller/BookController.java


@POST
@Transactional
@Path("/{id}/like")
@Produces(MediaType.APPLICATION_JSON)
public Response likeBook(@PathParam("id") String id) {
    Optional<Book> optional = Book.findByIdOptional(id);
    Book book = optional.orElseThrow(() -> new NotFoundException("Book not found"));
    book.setLikes(book.getLikes() + 1);
    return Response.ok(book).build();
    }

We can now check the correctness of the endpoint by running

terminal


curl -XPOST localhost:8080/books/1/like | jq
# {
#   "id": 1,
#   "title": "The Aleph",
#   "author": "Jorge Luis Borges",
#   "likes": 101
# }

...And that's all folks! We're now moving onto building a frontend where we'll consume the Quarkus backend.

Setting up the Next.js Frontend

We can bootstrap the application by running npx create-next-app@latest. We're going to use TailwindCSS for styling; which will imply inlining the css directly onto our HTML tags using short abbreviations. We'll also use Next.js' Server Actions to structure a backend-for-frontend, whereas the frontend contains a mini-backend which is concerned with data fetching.

terminal


npx create-next-app@latest
# ✔ What is your project named? … cutebooks-frontend
# ✔ Would you like to use TypeScript? …  Yes
# ✔ Would you like to use ESLint? …  Yes
# ✔ Would you like to use Tailwind CSS? …  Yes
# ✔ Would you like to use `src/` directory? …  Yes
# ✔ Would you like to use App Router? (recommended) …  Yes
# ✔ Would you like to customize the default import alias (@/*)? … No

We'll start out by resetting the globals.css to remove the placeholder css, leaving only the tailwind base styles:

src/app/globals.css


@tailwind base;
@tailwind components;
@tailwind utilities;

Next.js uses file-based routing. That implies that every page.tsx file inside any folder will handle the view for that specific route. We'll start by changing the landing page located at app/page.tsx to:

src/app/page.tsx


export default function Home() {
  return (
      <div className="flex flex-col items-center justify-center p-16 gap-4">
        <h1 className="text-4xl">Cutebooks</h1>
          {/* books go here */}
      </div>
  );
}

Now, create a new file at src/ui/SingleBook.tsx. This will receive the data for a single book and render the component. Feel free to edit the styling to fit your needs, and feel free to get creative!

src/ui/SingleBook.tsx


    interface SingleBookProps {
    title: string,
    author: string,
    likes: number,
    id: number,
}

const SingleBook = ({id, author, likes, title}: SingleBookProps) => {
    return (
        <div className="border-4 border-gray-200 shadow-md p-4 rounded-lg">
            <h2 className="text-xl font-bold mb-2">{title} by {author}</h2>
            <div className="flex flex-row gap-4 items-center justify-center">
                <p className="text-gray-700 text-center flex justify-center items-center">
                    Total likes: {likes}
                </p>
                {/* Inlined thumbs up SVG */}
                <svg
                    style={{cursor: "pointer"}}
                    xmlns="http://www.w3.org/2000/svg"
                    width="30"
                    height="30"
                    fill="none"
                    stroke="#2c3e50"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth="1.5"
                    viewBox="0 0 24 24"
                >
                    <path stroke="none" d="M0 0h24v24H0z"></path>
                    <path
                        d={"M7 11v8a1 1 0 01-1 1H4a1 1 0 01-1-1v-7a1 1 0 011-1h3a4 4 " +
                            "0 004-4V6a2 2 0 014 0v5h3a2 2 0 012 2l-1 5a2 3 0 01-2 2h-7a3 3 0 01-3-3"}>
                    </path>
                </svg>
            </div>
            {/* End of thumbs up SVG */}
        </div>
    );
};


export default SingleBook;

We'll then add this new <SingleBook/> to our src/app/page.tsx and seed it with our own data. It should end up looking something like:

first iteration of the landing page

Looks good! Now, we'll create our Server Actions to communicate with our Quarkus backend. We'll use the built-in fetch to create HTTP requests to our Quarkus app. We'll create actions to getAllBooks, getBookById and likeBookById.

src/action/bookActions.ts



    interface Book {
    id: number,
    title: string,
    author: string,
    likes: number,
}

async function getAllBooks(): Promise<Book[]> {
    return await fetch("http://localhost:8080/books", {
        method: "GET",
        cache: "no-cache"

    }).then(ans => ans.json());
}

async function getBookById(id: number): Promise<Book> {
    return await fetch(`http://localhost:8080/books/${id}`, {
        method: "GET"
    }).then(ans => ans.json());
}

async function likeBookById(id: number): Promise<Book> {
    return await fetch(`http://localhost:8080/books/${id}/like`, {
        method: "POST"
    }).then(ans => ans.json());
}

export {getBookById, getAllBooks, likeBookById}

Great! Now, we can modify our landing page to fetch all books and render the list of elements onto a React component. We can achieve this by modifying our landing page.tsx like so:

src/app/page.tsx



import SingleBook from "@/ui/SingleBook";
import {getAllBooks, getBookById} from "@/action/bookActions";

export default async function Home() {
    const books = await getAllBooks();
    return (
        <div className="flex flex-col items-center justify-center p-16 gap-4">
            <h1 className="text-4xl">Cutebooks</h1>
            {books.map((book, idx) => {
                return (<SingleBook
                    key={idx}
                    title={book.title}
                    author={book.author}
                    likes={book.likes}
                    id={book.id}/>)
            })}
        </div>
    );
}

We're almost done! We'll now refactor the component to be able to like books and update the UI accordingly. We'll add an onClick callback which triggers our likeBookById function. We'll then set the total likes in the UI to the server response.

ui/SingleBook.tsx


"use client"
import {useState} from "react";
import {likeBookById} from "@/action/bookActions";

interface SingleBookProps {
    title: string,
    author: string,
    likes: number,
    id: number,
}

const SingleBook = ({id, author, likes, title}: SingleBookProps) => {
    const [currentLikes, setCurrentLikes] = useState(likes);
    return (
        <div className="border-4 border-gray-200 shadow-md p-4 rounded-lg">
            <h2 className="text-xl font-bold mb-2">{title} by {author}</h2>
            <div className="flex flex-row gap-4 items-center justify-center">
                <p className="text-gray-700 text-center flex justify-center items-center">
                    Total likes: {currentLikes}
                </p>
                {/* Inlined thumbs up SVG */}
                <svg onClick={() => {
                    likeBookById(id)
                        .then(r => setCurrentLikes(r.likes))
                }}
                     style={{cursor: "pointer"}}
                     xmlns="http://www.w3.org/2000/svg"
                     width="30"
                     height="30"
                     fill="none"
                     stroke="#2c3e50"
                     strokeLinecap="round"
                     strokeLinejoin="round"
                     strokeWidth="1.5"
                     viewBox="0 0 24 24"
                >
                    <path stroke="none" d="M0 0h24v24H0z"></path>
                    <path
                        d={"M7 11v8a1 1 0 01-1 1H4a1 1 0 01-1-1v-7a1 1 0 011-1h3a4 4 " +
                            "0 004-4V6a2 2 0 014 0v5h3a2 2 0 012 2l-1 5a2 3 0 01-2 2h-7a3 3 0 01-3-3"}>
                    </path>
                </svg>
            </div>
            {/* End of thumbs up SVG */}
        </div>
    );
};
export default SingleBook;

Conclusion

It's hard to draw insightful conclusions from an introductory and shallow exploration of a technology. This demo was mostly intended to display developer ergonomics. At a glance, both technologies are ergonomic and easy to develop in.

Quarkus' instant-feedback dev service, auto-start devcontainers and comprehensive CLI capabilities make it a joy to work with. The ability to quickly whip up a production ready image with quarkus build image is also a breath of fresh air. Some of its strongest points, like a low memory footprint, high throughput and improved startup times are out of scope for this article. But in raw developer experience terms, it sure lives up to its promise.

Next.js' approach of file-based routing and React Server Components is an objective improvement over the now deprecated create-react-app. The use of Server Actions as a data fetching mechanism is also a great improvement over the previous useEffect pattern. The new and ambitious App Router is fresh and overcomes many of the common challenges of a CRA app: built-in routing, server actions, Suspense and Image. These capabilities are a welcome addition! You can find the source code in this GitHub Repository