Build your own app
This guide takes the GRANDstack repos and turns them into an app for your own data. The worked example is a small library: Books, Authors and Genres, with a relationship property on who wrote what. Every step here was run end to end: the Books server answered queries, codegen generated types from its schema, and a typed Books page built and rendered.
Where to run this. Docker Compose commands run inside a clone of this repository (
git clone https://github.com/grand-stack/grandstack, thencd grandstack), with the repositories it builds cloned beside it. The quickstart has the exact steps.
You will:
- Describe your data in a GraphQL schema and a seed file.
- Run the server on it (no code changes, no rebuild).
- Generate types and build a front end that reads it.
Time: about 20 minutes. You need Docker or Podman with compose, Node 22 or later, and the three repositories (grandstack, grandstack-server, grandstack-web) side by side in one folder.
Part 1: the server
1. Write the schema
Create grandstack/my-app/schema.graphql. Rules (details in the server’s docs/schema-guide.md): every type that is stored in Neo4j needs @node, relationships are declared from both sides, and every type and field should have a description.
"""A book in the library."""
type Book @node {
"""Book title. Unique."""
title: String!
"""Year of first publication."""
published: Int
"""Number of pages."""
pages: Int
"""Average reader rating from 0 to 5."""
rating: Float
"""Genres the book belongs to."""
genres: [Genre!]! @relationship(type: "IN_GENRE", direction: OUT)
"""People who wrote or contributed to the book."""
authors: [Author!]! @relationship(type: "WROTE", direction: IN, properties: "Wrote")
}
"""A person who wrote books."""
type Author @node {
"""Full name. Unique."""
name: String!
"""Year of birth."""
born: Int
"""Books the person wrote."""
books: [Book!]! @relationship(type: "WROTE", direction: OUT, properties: "Wrote")
}
"""A book genre."""
type Genre @node {
"""Genre name."""
name: String!
"""Books in this genre."""
books: [Book!]! @relationship(type: "IN_GENRE", direction: IN)
}
"""Properties on the WROTE relationship."""
type Wrote @relationshipProperties {
"""How the person contributed, for example author or translator."""
role: String!
}
direction is relative to the type the field sits on: Book.authors is IN (the arrow goes from Author to Book), Author.books is OUT for the same WROTE relationship.
2. Write the seed
Create grandstack/my-app/books.cypher. Statements end with a semicolon at the end of a line, use MERGE so reloading is safe, and the first line carries a version.
// seed-version: 1
CREATE CONSTRAINT book_title IF NOT EXISTS FOR (b:Book) REQUIRE b.title IS UNIQUE;
CREATE CONSTRAINT author_name IF NOT EXISTS FOR (a:Author) REQUIRE a.name IS UNIQUE;
CREATE CONSTRAINT genre_name IF NOT EXISTS FOR (g:Genre) REQUIRE g.name IS UNIQUE;
UNWIND [
{title: "Dune", published: 1965, pages: 412, rating: 4.3, genres: ["Sci-Fi"], authors: [{name: "Frank Herbert", role: "author"}]},
{title: "Neuromancer", published: 1984, pages: 271, rating: 3.9, genres: ["Sci-Fi"], authors: [{name: "William Gibson", role: "author"}]},
{title: "Good Omens", published: 1990, pages: 288, rating: 4.3, genres: ["Fantasy", "Comedy"], authors: [{name: "Terry Pratchett", role: "author"}, {name: "Neil Gaiman", role: "author"}]},
{title: "The Hobbit", published: 1937, pages: 310, rating: 4.3, genres: ["Fantasy"], authors: [{name: "J. R. R. Tolkien", role: "author"}]},
{title: "Emma", published: 1815, pages: 474, rating: 4.0, genres: ["Romance"], authors: [{name: "Jane Austen", role: "author"}]}
] AS b
MERGE (book:Book {title: b.title}) SET book.published = b.published, book.pages = b.pages, book.rating = b.rating
FOREACH (g IN b.genres | MERGE (ge:Genre {name: g}) MERGE (book)-[:IN_GENRE]->(ge))
FOREACH (a IN b.authors | MERGE (au:Author {name: a.name}) MERGE (au)-[r:WROTE]->(book) SET r.role = a.role);
Map keys in Cypher are bare words (title:), not quoted strings.
3. Point the server at them
Create grandstack/compose.books.yaml. It overrides the server service so the image uses your files and settings instead of the Movies ones, with no rebuild:
services:
server:
environment:
SEED_PATH: /app/seed/books.cypher # which seed file to load
SEED_PROBE_LABEL: Book # label used to tell whether the database already has data
REFRESH_COUNTS: "false" # the Movies sample keeps film counts on Person; you have none
volumes:
- ./my-app/schema.graphql:/app/schema/schema.graphql:ro
- ./my-app/books.cypher:/app/seed/books.cypher:ro
4. Start Neo4j and the server
Stop the Movies stack first if it is running (it uses the same ports), then start only Neo4j and the server under their own project name so the Movies data is not mixed in:
cd grandstack
docker compose down
docker compose -p mybooks -f compose.yaml -f compose.books.yaml up --build neo4j server
The server log should say Loaded seed and GraphQL ready at http://localhost:4000/graphql. Open that URL for GraphiQL, or:
curl -s localhost:4000/graphql -H 'content-type: application/json' \
-d '{"query":"{ books(sort:[{rating:DESC}]) { title published authors { name } genres { name } } }"}'
5. Try writes
Everything the library generates for Book works with no more code: filters, sorting, booksConnection with totalCount, and create, update and delete. This one connects an existing author and creates a new one, setting the relationship property on each:
mutation($i: [BookCreateInput!]!) {
createBooks(input: $i) { books { title authorsConnection { edges { properties { role } node { name } } } } }
}
{ "i": [{ "title": "Anathem", "published": 2008,
"authors": { "connect": [{ "where": { "node": { "name": { "eq": "William Gibson" } } }, "edge": { "role": "translator" } }],
"create": [{ "node": { "name": "Neal Stephenson" }, "edge": { "role": "author" } }] } }] }
The rules for updates and deletes, including how to change relationships safely, are in the server’s docs/mutations.md.
6. Change the seed later
Edit books.cypher and bump // seed-version; on the next server start the database reloads it. Without a bump nothing happens. Note: the marker node is named movies regardless of your data (an implementation detail, harmless). Check the log for Loaded seed or Seed already loaded, skipping.
Part 2: a front end
The web app is written for Movies. Codegen shows exactly what depends on that.
7. Copy the web app and see what breaks
cp -R grandstack-web my-library-web
cd my-library-web
rm -rf node_modules dist && npm ci
npm run codegen
With the Books server running, codegen fails with a list like Cannot query field "movies" on type "Query" (49 errors for the stock app). That list is every place the app assumes Movies, which is the point: the schema is the contract.
8. Keep what is generic, remove what is not
rm -rf src/pages src/search src/forms src/graph e2e docs
rm src/lib/filters.ts src/lib/filters.test.ts src/lib/peopleFilters.ts src/lib/peopleFilters.test.ts src/lib/compare.tsx
npm pkg delete scripts.e2e scripts.docs:check
What stays and is reusable as it is: src/ui (buttons, chips, dialog, avatar), src/poster (generated posters), src/theme and styles.css, and src/lib/hash.ts and initials.ts. See the web repo’s docs/customising.md for the full table.
9. Write a typed page
Create src/pages/Books.tsx. The query in graphql() is validated against your schema and typed by codegen:
import { useState } from "react";
import { useQuery } from "urql";
import { graphql } from "../graphql/generated";
import { Poster } from "../poster/Poster";
import { RatingBadge } from "../ui";
const BooksQuery = graphql(`
query Books($search: String!) {
books(where: { title: { contains: $search } }, sort: [{ rating: DESC }]) {
title
published
pages
rating
genres { name }
authorsConnection {
edges {
properties { role }
node { name }
}
}
}
}
`);
export function Books() {
const [search, setSearch] = useState("");
const [{ data, fetching, error }] = useQuery({ query: BooksQuery, variables: { search } });
return (
<section>
<input className="text-input" placeholder="Search by title (case-sensitive)" value={search}
onChange={(e) => setSearch(e.target.value)} aria-label="Search books" />
{error && <p className="error">{error.message}</p>}
{fetching && !data && <p>Loading…</p>}
<div className="grid" style={{ marginTop: 24 }}>
{data?.books.map((b) => (
<article key={b.title} className="card">
<Poster title={b.title} year={b.published} genre={b.genres[0]?.name} />
<div className="card-body">
<div className="card-head">
<strong className="card-title">{b.title}</strong>
<RatingBadge rating={b.rating} />
</div>
<p className="card-meta">
{b.authorsConnection.edges.map((e) => `${e.node.name} (${e.properties.role})`).join(", ")}
</p>
<p className="card-genres">{b.genres.map((g) => g.name).join(" · ")}{b.pages ? ` · ${b.pages} pages` : ""}</p>
</div>
</article>
))}
</div>
</section>
);
}
Replace src/App.tsx with:
import { Books } from "./pages/Books";
export function App() {
return (
<main>
<h1>My Library</h1>
<Books />
</main>
);
}
And replace src/main.tsx with this version, which drops the router (you removed every route):
import "@fontsource-variable/public-sans";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { MotionConfig } from "motion/react";
import { Client, Provider, cacheExchange, fetchExchange } from "urql";
import { App } from "./App";
import "./theme/tokens.css";
import "./styles.css";
const client = new Client({
url: import.meta.env.VITE_GRAPHQL_URL ?? "http://localhost:4000/graphql",
exchanges: [cacheExchange, fetchExchange],
requestPolicy: "cache-and-network",
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider value={client}>
<MotionConfig reducedMotion="user">
<App />
</MotionConfig>
</Provider>
</StrictMode>,
);
10. Generate types, check, run
npm run codegen # must succeed now: it validates your query against the Books schema
npm run typecheck
npm test
npm run dev # http://localhost:3000
You should see six book cards with generated posters, authors with their roles and genres. (The dev server uses port 3000, which the server allows by default through CORS_ORIGIN.)
Where to go next
Use the web repo’s docs/recipes.md for the patterns, adapted to your entities:
- Filters in the URL: copy the idea of
src/lib/filters.ts(parse, serialize,buildWhere) in the original repo for your own fields. - Facet counts, people browser, compare, graph views: see the web repo’s
docs/features.mdfor how each works. - Create, edit, delete forms:
src/forms/model.tsin the original repo shows how form state becomes nested mutation inputs and relationship diffs. - Computed fields: add
@cypherfields likeMovie.similar(seedocs/schema-guide.mdingrandstack-server). - Keep the server’s rules in mind: describe every field, bump the seed version, and update relationships as diffs.
Adding login to your app
The auth setup in grandstack-auth is not tied to Movies. The pieces you would reuse:
- The gateway (
gateway/) is generic: pointUPSTREAM_URLat your server, set the issuer, audience and JWKS URL, and list your mutations inMUTATION_PERMISSIONSingateway/rules.tswith the permission each needs (unknown mutations are refused). - Row-level rules live in an extra schema file for your own types (see
schema/auth.graphqlforfilterrules,$jwt.subcomparisons and the “author comes from the token” pattern). The server loads it withSCHEMA_EXTRA_PATHSand verifies tokens when the threeAUTH_*variables are set. Mind the v7 pitfalls:validaterules andDateTimeneed APOC. - Roles and users come from your identity provider; the dev realm file shows the Keycloak side, and
docs/swapping-providers.mdlists what to change for another provider. - A front end needs an OpenID Connect client;
grandstack-auth/docs/exercise-add-auth-to-grandstack-web.mdwalks through it.
What is Movies-specific and what is not
| Reusable as is | Pattern to copy | Movies-specific |
|---|---|---|
The server image, config, plugin mechanism, seed versioning, src/ui, posters, theme, graph view component | URL-synced filters, facet counts, diff-based update inputs, typed operations | schema/schema.graphql, seed/movies.cypher, src/pages, src/search, the people and movie filters |
Source: grandstack/docs/build-your-own-app.md (this page is a copy; edit it in the repository)