After watching a few glossy new videos from the Next.js Conf 2022, I thought I'd take a closer look at Next.js and see how the framework could help me to build my next Neo4j-based web application.
Fundamentally, adding Neo4j integration to a Next.js project is similar to any other Node.js/TypeScript-based project. However, the various Data Fetching methods and both Server-side and Client-side rendering raise some interesting challenges.
Let's take a look at how we can use Neo4j in a Next.js project.
What is Next.js?
Next.js is a React-based framework which provides an opinionated starting point for building web applications. The framework provides building blocks for many of the common features that developers need to consider when building modern applications such as UI components, Data Fetching, and Rendering.
The framework also focuses on performance, providing the ability to pre-generate static HTML pages using Static-site Generation (SSG), render HTML on the server at request time using Server-side rendering (SSR) and also render React components on the client-side using Client-side Rendering (CSR).
You can read more about Next.js here.
What is Neo4j?
The chances are, if you have found this article via search, you'd know more about Next.js than Neo4j. Neo4j is a Graph Database, a database consisting of Nodes - which represent entities or things, connected together and Relationships.
Neo4j comes into its own when working with highly connected datasets or as an alternative for complex relational database schemas where many joins are required. The golden rule is that if your queries have three or more joins, you should really be looking at using a graph database.
You can read more about Neo4j here.
Why Neo4j and Next.js?
Next.js is gaining momentum as one of the most popular frameworks for building modern web applications. The benefit of using Next.js is that your front-end and back-end code are all self-contained within the same subfolders of the api/
directory.
If you are building a Neo4j-backed project, building an integration with the Neo4j JavaScript Driver is relatively straightforward. All you need to do is create a new instance of the driver within the application, then use the driver to execute Cypher statements and retrieve results.
Of course, you can use the Neo4j JavaScript driver directly from React components, but this means exposing database credentials through the client which can be a security risk. Instead, if you require on-demand data from Neo4j in client-side rendering, you can create an API handler to execute the Cypher statement server-side and return results.
Creating a free Neo4j AuraDB Instance
Neo4j AuraDB, Neo4j's fully managed cloud service provides one AuraDB Free instance to all users, completely free and no credit card is required.
If you sign in or register for Neo4j Aura at cloud.neo4j.io, you will see a New Instance button at the top of the screen. If you click this button, you will be able to choose between an empty database or one pre-populated with sample data.
For this article, I suggest choosing the Graph-based Recommendations
dataset, which consists of Movies, Actors, Directors and user ratings. This dataset is a nice introduction to graph concepts and can be used to build a movie recommendation algorithm. We use it across GraphAcademy, including the Building Neo4j Applications with Node.js course.
Click Create to create your instance. Once you have done so, a modal window will appear with a generated password.
Click the Download button to download your credentials, we'll need these a little later on. After a couple of minutes, your instance will be ready to explore. You can click the Explore button to explore the graph with Neo4j Bloom, or query the graph using Cypher by clicking the Query tab.
You can take a look at that in your own time, for now, let's focus on our Next.js application.
Creating a new Next.js Project
You can create a new Next.js project from a template using the Create Next App CLI command.
npx create-next-app@latest
The command will prompt you for a project name and install any dependencies.
Adding Neo4j Helper Functions
To install the Neo4j JavaScript Driver, first install the dependency:
npm install --save neo4j-driver
# or yarn add neo4j-driver
Next.js comes with built-in support for Environment Variables, so we can simply copy the credentials file downloaded from the Neo4j Aura Console above, rename it to .env
and place in the directory root.
We can then access those variables through the process.env
variable:
const { NEO4J_URI, NEO4J_USERNAME, NEO4J_PASSWORD } = process.env
Next, create a new folder called lib/
and then create a new neo4j.js
file. You will want to import the neo4j
object from the neo4j-driver
dependency and use the credentials above to create a driver instance
// lib/neo4j.js
const driver = neo4j.driver(
process.env.NEO4J_URI,
neo4j.auth.basic(
process.env.NEO4J_USERNAME,
process.env.NEO4J_PASSWORD
)
)
When executing a Cypher statement against a Neo4j instance, you need to open a session, and execute the statement within a read or write transaction. This can become a bit cumbersome after a while, so instead, I recommend writing helper functions for read and write queries:
// lib/neo4j.js
export async function read(cypher, params = {}) {
// 1. Open a session
const session = driver.session()
try {
// 2. Execute a Cypher Statement
const res = await session.executeRead(tx => tx.run(cypher, params))
// 3. Process the Results
const values = res.records.map(record => record.toObject())
return values
}
finally {
// 4. Close the session
await session.close()
}
}
export async function write(cypher, params = {}) {
// 1. Open a session
const session = driver.session()
try {
// 2. Execute a Cypher Statement
const res = await session.executeWrite(tx => tx.run(cypher, params))
// 3. Process the Results
const values = res.records.map(record => record.toObject())
return values
}
finally {
// 4. Close the session
await session.close()
}
}
If you want a deeper dive into this code or best practices I recommend that you check out the Neo4j & Node.js Course on GraphAcademy.
Now that we have a way to query Neo4j, let's look at the options for Data Fetching in Next.js
Data Fetching in Next.js
Next.js allows the rendering of content in several ways.
- Static-site Generation (SSG) - where static HTML pages are generated at build time
- Server-side Rendering (SSR) - HTML is generated server-side as a request comes in
- Client-side Rendering (CSR) - HTTP requests are executed in the browser with JavaScript and the response updates the DOM
Depending on your use case, you may need a mixture of these methods. Say you are running a movie recommendation site, it may make sense to use SSG to pre-build marketing pages. Movie information is held in a database and changes regularly, so these pages should be rendered by the server using SSR. When a user comes to rate a movie, the interaction should take place via an API request and the result rendered using CSR.
Let's take a look at the implementation of each of these records.
Static Page Generation
Let's say, for example, that generic genre pages won't change often and they don't require any user interaction. By generating static pages, we can serve cached versions of the pages and take the load away from the server.
Any component in the pages/
directory which exports a getStaticProps()
function (known as a Page) will be generated at build time and served as a static file.
Components created in the pages folder will automatically be mapped to a route. To create a page that will be available at /genres
you will need to create a pages/genres/index.jsx
file. The component needs to export a default
function which returns a JSX component, and a getStaticProps()
function.
First, to get the data required by the component, create the getStaticProps()
function and execute this Cypher statement in a read transaction.
// pages/genres/index.jsx
export async function getStaticProps() {
const res = await read(`
MATCH (g:Genre)
WHERE g.name <> '(no genres listed)'
CALL {
WITH g
MATCH (g)<-[:IN_GENRE]-(m:Movie)
WHERE m.imdbRating IS NOT NULL AND m.poster IS NOT NULL
RETURN m.poster AS poster
ORDER BY m.imdbRating DESC LIMIT 1
}
RETURN g {
.*,
movies: toString(size((g)<-[:IN_GENRE]-(:Movie))),
poster: poster
} AS genre
ORDER BY g.name ASC
`)
const genres = res.map(row => row.genre)
return {
props: {
genres,
}
}
}
Anything returned inside props
from this function will be passed as a prop into the default component.
Now, export a default function which displays a list of Genres.
// pages/genres/index.jsx
export default function GenresList({ genres }) {
return (
<div>
<h1>Genres</h1>
<ul>
{genres.map(genre => <li key={genre.name}>
<Link href={`/genres/${genre.name}`}>{genre.name} ({genre.movies})</Link>
</li>)}
</ul>
</div>
)
}
This should generate an unordered list of links for each Genre:
Looking good...
If you run the npm run build
command, you will see a genres.html
file inside the .next/server/pages/
directory.
Using Neo4j for Server-side Rendering
The movie list on each genre page may change often, or you may wish to add extra interaction to the page. In this case, it makes sense to render this page on the server. By default, Next.js will cache this page for a short amount of time which is perfect for websites with high amounts of traffic.
Each genre link on the previous page links to /genres/[name]
- for example /genres/Action
. By creating a pages/genres/[name].jsx
file, Next.js knows automatically to listen for requests on any URL starting with /genres/
and detect anything after the slash as a name
URL parameter.
This can be accessed by the getServerSideProps()
function, which will instruct Next.js to render this page using Server-side Rendering as the request comes in.
The getServerSideProps()
function should be used to get the data required to render the page and return it inside a props
key.
export async function getServerSideProps({ query, params }) {
const limit = 10
const page = parseInt(query.page ?? '1')
const skip = (page - 1) * limit
const res = await read(`
MATCH (g:Genre {name: $genre})
WITH g, size((g)<-[:IN_GENRE]-()) AS count
MATCH (m:Movie)-[:IN_GENRE]->(g)
RETURN
g { .* } AS genre,
toString(count) AS count,
m {
.tmdbId,
.title
} AS movie
ORDER BY m.title ASC
SKIP $skip
LIMIT $limit
`, {
genre: params.name,
limit: int(limit),
skip: int(((query.page || 1)-1) * limit)
})
const genre = res[0].genre
const count = res[0].count
return {
props: {
genre,
count,
movies: res.map(record => record.movie),
page, skip, limit,
}
}
}
In the example above, I get the movie name from the params
object in the request context which is passed as the only argument to the getServerSideProps()
function. I also attempt to get the ?page=
query parameter from the URL to provide a paginated list of movies.
These values will again be passed as props into the default function, and can therefore be used to list the movies and pagination links.
export default function GenreDetails({ genre, count, movies, page, skip, limit }) {
return (
<div>
<h1>{genre.name}</h1>
<p>There are {count} movies listed as {genre.name}.</p>
<ul>
{movies.map(movie => <li key={movie.tmdbId}>{movie.title}</li>)}
</ul>
<p>
Showing page #{page}. <br />
{page > 1 ? <Link href={`/genres/${genre.name}?page=${page-1}`}> Previous</Link> : ' '}
{' '}
{skip + limit < count ? <Link href={`/genres/${genre.name}?page=${page+1}`}>Next</Link> : ' '}
</p>
</div>
)
}
Next.js then renders a list of movies with each request.
Using Neo4j for Client-side Data Fetching
As it stands, for each click of the Previous and Next links above, the entire page will reload which isn't ideal. Although this is a trivial example so far, loading KBs worth of HTML again to render the header and footer means additional load on the server and more data sent over the wire.
Instead, you could build a React component that would load the list of movies asynchronously through a client-side HTTP request. This would mean that the list of movies could be updated without reloading the entire page, providing the end-user with a smoother viewing experience.
To support this, we will have to create a API Route which will return a list of movies as JSON.
Any file in the pages/api/
directory is treated as a route handler, a single default exported function which accepts request and response parameters, and expects an HTTP status and response to be returned.
So to create an API route to serve a list of movies at http://locahost:3000/api/movies/[name]/movies
, create a new movies.js
file in the pages/api/genres/[name]
folder.
// pages/api/genres/[name]/movies.js
export default async function handler(req, res) {
const { name } = req.query
const limit = 10
const page = parseInt(req.query.page as string ?? '1')
const skip = (page - 1) * limit
const result = await read<MovieResult>(`
MATCH (m:Movie)-[:IN_GENRE]->(g:Genre {name: $genre})
RETURN
g { .* } AS genre,
toString(size((g)<-[:IN_GENRE]-())) AS count,
m {
.tmdbId,
.title
} AS movie
ORDER BY m.title ASC
SKIP $skip
LIMIT $limit
`, {
genre: name,
limit: int(limit),
skip: int(skip)
})
res.status(200).json({
total: parseInt(result[0]?.count) || 0,
data: result.map(record => record.movie)
})
}
The function above executes a Cypher statement in a read transaction, processes the results and returns the list of
movies as a JSON response.
A quick GET request to http://localhost:3000/api/genres/Action/movies shows a list of movies:
[
{
"tmdbId": "72867",
"title": "'Hellboy': The Seeds of Creation"
},
{
"tmdbId": "58857",
"title": "13 Assassins (Jûsan-nin no shikaku)"
},
/* ... */
]
This API handler can then be called through a React component in a useEffect
hook.
// components/genre/movie-list.tsx
export default function GenreMovieList({ genre }: GenreMovieListProps) {
const [page, setPage] = useState<number>(1)
const [limit, setLimit] = useState<number>(10)
const [movies, setMovies] = useState<Movie[]>()
const [total, setTotal] = useState<number>()
// Get data from the API
useEffect(() => {
fetch(`/api/genres/${genre.name}/movies?page=${page}&limit=${limit}`)
.then(res => res.json())
.then(json => {
setMovies(json.data)
setTotal(json.total)
})
}, [genre, page, limit])
// Loading State
if (!movies || !total) {
return <div>Loading...</div>
}
return (
<div>
<ul>
{movies.map(movie => <li key={movie.tmdbId}>{movie.title}</li>)}
</ul>
<p>Showing page {page}</p>
{page > 1 && <button onClick={() => setPage(page - 1)}>Previous</button>}
{page * limit < total && <button onClick={() => setPage(page + 1)}>Next</button>}
</div>
)
}
The component is then in charge of pagination and any update to the list doesn't re-render the entire page.
Conclusion
This is far from a comprehensive guide to Next.js or Neo4j integrations but hopefully, it serves as a quick reference for anyone wondering the best way to integrate Neo4j, or any other database for that matter, with a Next.js application.
All of the code from this experiment is available on Github.
If you are interested in learning more about Next.js, they have put together a course for developers to learn the basics.
If you would like to learn more about Neo4j, then I would recommend taking a look at the Beginners Neo4j Courses on GraphAcademy. If you want to know more about how to use the Neo4j JavaScript Driver in a Node.js or Typescript project, I would also recommend the Building Neo4j Applications with Node.js course.
If you have any comments or questions, feel free to reach out to me on Twitter.
Top comments (0)