Are you looking to build a type-safe, full-stack TypeScript application? In this post, we'll show you how to combine Gel's powerful data layer with Drizzle ORM and Next.js to create a Book Notes app that helps users track their reading and add notes about books they've read.
The complete source code for this project is available in our Gel Examples repository.
The Stack
Our application uses three main technologies that work nicely together:
-
Gel provides a robust, type-safe database layer with a powerful schema system
-
Drizzle ORM offers a clean TypeScript API for database operations without compromising on type safety
-
Next.js handles our UI and API routes with server components and built-in routing
This combination gives us end-to-end type safety, excellent developer experience, and the ability to build a full-stack application with minimal boilerplate.
But why choose Gel over other PostgreSQL solutions? Gel significantly enhances the core PostgreSQL feature set with a high-level object model that is fully type-safe, intuitive, and offers advanced features like mixins and access control. Additionally, it provides a hierarchical, graph-like query language (EdgeQL), a built-in and free authentication layer, and many other benefits.
Gel truly excels in projects that leverage multiple languages and frameworks on the backend by offering a unified data model and APIs that are both consistent and type-safe across all combinations.
Getting started
Let's start by setting up our development environment. We'll create a Next.js application with TypeScript support and Tailwind CSS for styling, then add Gel and Drizzle to handle our data layer.
$
# Create Next.js app with TypeScript and Tailwind CSS
$
npx create-next-app@latest book-notes-app
$
cd book-notes-app
$
# Install Gel and Drizzle dependencies
$
npm i gel @gel/generate drizzle-orm drizzle-kit
# Initialize Gel project
$
npx gel project init
When you run gel project init
, Gel sets up a local instance and creates necessary configuration files. It also installs Postgres for you. It gives you a development database right out of the box, with no separate database setup required.
Defining your Gel schema
With our project initialized, it's time to define our data model. For our Book Notes app, we need two main types:
-
Book
- to store information about books we've read -
Note
- to store notes associated with each book
Gel's schema language is expressive and intuitive. Let's create our schema:
module default {
type Book {
required title: str;
author: str;
year: int16;
genre: str;
read_date: datetime;
multi notes := .<book[is Note];
}
type Note {
required text: str;
created_at: datetime {
default := datetime_current();
}
required book: Book;
}
}
This schema defines a relationship between books and notes. Each note belongs to exactly one book (via the book
link), and each book can have multiple notes (via the computed notes
link).
The datetime_current()
function automatically sets the creation time for new notes.
Now, let's apply this schema to our database:
$
npx gel migration create
$
npx gel migrate
The first command generates a migration file that represents the changes to apply to our database. The second command applies those changes. Gel handles the creation of tables, constraints, and relationships for us.
Connecting Drizzle to Gel
With our Gel schema in place, it's time to integrate Drizzle ORM. Drizzle will provide a type-safe TypeScript interface to interact with our Gel database.
First, we create a configuration file for Drizzle:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'gel',
});
This tells Drizzle that we're using Gel as our database and specifies where to put generated files.
Next, we pull the Gel schema into Drizzle:
$
npx drizzle-kit pull
The pull
command introspects our Gel database and generates TypeScript files that describe our schema for Drizzle. This creates a seamless integration between Gel's schema and Drizzle's query building capabilities. The generated files will be placed in the drizzle
directory.
Now, let's create a database client that we'll use throughout our application:
import { drizzle } from 'drizzle-orm/gel';
import * as schema from '../../drizzle/schema';
import * as relations from '../../drizzle/relations';
// Initialize Gel client
const gelClient = createClient();
// Create Drizzle instance with our schema
export const db = drizzle({
client: gelClient, schema: {
...schema,
...relations
},
});
// Helper types for use in our application
export type Book = typeof schema.books.$inferSelect;
export type NewBook = typeof schema.books.$inferInsert;
export type Note = typeof schema.notes.$inferSelect;
export type NewNote = typeof schema.notes.$inferInsert;
This client connects to our Gel database and exposes the Drizzle API for querying and manipulating data. The helper types we export will be used throughout our application to ensure type safety.
To keep Drizzle in sync with Gel whenever we apply migrations, we'll add a hook in our gel.toml
file:
[hooks]
after_migration_apply = [
"npx drizzle-kit pull"
]
Building API Routes with Next.js
Now that our data layer is set up, we can create API endpoints using Next.js's App Router. These endpoints will handle CRUD operations for our books and notes.
Let's start with a route for getting all books and adding a new book:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { book } from '@/drizzle/schema';
export async function GET() {
try {
// Fetch all books with their notes
const allBooks = await db.query.book.findMany({
with: { notes: true },
});
return NextResponse.json(allBooks);
} catch (error) {
console.error('Error fetching books:', error);
return NextResponse.json(
{ error: 'Failed to fetch books' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
// Insert a new book
const result = await db.insert(book).values({
title: body.title,
author: body.author,
year: body.year,
genre: body.genre,
readDate: new Date(body.read_date),
}).returning();
return NextResponse.json(result[0], { status: 201 });
} catch (error) {
console.error('Error adding book:', error);
return NextResponse.json(
{ error: 'Failed to add book' },
{ status: 500 }
);
}
}
Notice how we're using Drizzle's query builder to interact with our Gel database. The findMany
function fetches all books, and the with
option includes related notes. The insert
function adds a new book to the database.
We also need routes for handling individual books and notes. For example, here's how we would handle getting, updating, and deleting a specific book:
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { book, note } from '@/drizzle/schema';
import { eq } from 'drizzle-orm';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
try {
// Find a book by ID with its notes
const book = await db.query.book.findFirst({
where: eq(book.id, id),
with: { notes: true },
});
if (!book) {
return NextResponse.json(
{ error: 'Book not found' },
{ status: 404 }
);
}
return NextResponse.json(book);
} catch (error) {
console.error('Error fetching book:', error);
return NextResponse.json(
{ error: 'Failed to fetch book' },
{ status: 500 }
);
}
}
// Similar implementations for PUT and DELETE...
Creating the UI
With our API routes in place, we can build the user interface for our application. We'll use React components with Tailwind CSS for styling.
Let's start with the home page that displays a list of all books:
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Book } from '@/db';
export default function Home() {
const [books, setBooks] = useState<Book[]>([]);
const [loading, setLoading] = useState(true);
// Fetch books when the component mounts
useEffect(() => {
async function fetchBooks() {
try {
const response = await fetch('/api/books');
if (!response.ok) throw new Error('Failed to fetch books');
const data = await response.json();
setBooks(data);
} catch (error) {
console.error('Error:', error);
} finally {
setLoading(false);
}
}
fetchBooks();
}, []);
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<p className="text-xl">Loading...</p>
</div>
);
}
return (
<main className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6">My Book Notes</h1>
<Link href="/books/add" className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded mb-6 inline-block">
Add New Book
</Link>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
{books.length === 0 ? (
<p>No books found. Add your first book!</p>
) : (
books.map((book) => (
<div key={book.id} className="border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow">
<h2 className="text-xl font-semibold">{book.title}</h2>
{book.author && <p className="text-gray-600">by {book.author}</p>}
{book.year && <p className="text-sm text-gray-500">Published: {book.year}</p>}
{book.genre && <p className="text-sm text-gray-500">Genre: {book.genre}</p>}
<p className="mt-2 text-sm">
{book.notes?.length || 0} notes
</p>
<Link href={`/books/${book.id}`} className="mt-3 text-blue-500 hover:text-blue-600 inline-block">
View Details
</Link>
</div>
))
)}
</div>
</main>
);
}
This component fetches all books from our API and displays them in a responsive grid. Each book card shows basic information and a link to view more details.
For a complete application, we also need forms for adding and editing books, a book details page, and components for managing notes. These follow a similar pattern:
-
Fetch data from our API
-
Manage state with React hooks
-
Render UI with Tailwind CSS
-
Handle user interactions (form submissions, button clicks, etc.)
Since these components follow the same patterns, we won't show all of them here. You can find the complete implementation in our GitHub repository.
Why this stack?
The combination of Gel, Drizzle, and Next.js provides several key advantages:
-
End-to-end type safety: Types flow from our Gel schema through Drizzle and into our React components. This catches errors at compile time rather than runtime.
-
Developer productivity: Each tool excels at its specific job - Gel for data modeling, Drizzle for query building, and Next.js for UI and routing. This leads to cleaner, more maintainable code.
-
Scalability: This architecture can grow with your application's needs. You can add authentication, more complex data models, and additional features without changing the core.
Next steps
There are many ways to extend this application:
-
Add authentication to support multiple users
-
Implement filtering and searching for books
-
Create a statistics dashboard for reading habits
-
Add import/export functionality
-
Implement full-text search for notes
Conclusion
In this post, we've seen how to build a Book Notes application using Gel, Drizzle, and Next.js. This combination provides a powerful, type-safe foundation for full-stack TypeScript applications.
The key takeaways are:
-
Gel provides a robust foundation for your data layer with its powerful schema system
-
Drizzle offers a type-safe and intuitive way to interact with your Gel database
-
Next.js makes it easy to build both API routes and UI components
-
Together, these tools create a seamless developer experience
Ready to try it yourself? Check out the complete example code or explore our documentation to learn more about Gel.
Want to dive deeper? We've created a comprehensive step-by-step tutorial in our documentation that walks you through building this application from scratch, with detailed explanations and full code examples.
Happy coding!