Web Development with Quarkus and Next.js
by Bautista Bambozzi — 60 min

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.
- B) Have the Quarkus CLI installed.
- C) Have Docker installed.
- 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 POST
ed 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:

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