Writing a graphQL fullstack Svelte Web App with Sapper & Postgraphile
Finally I've got some free time for writing, so today I would like to share with you a simple yet powerfull way to write full stack web apps using my favorite tools:
- Sapper/Svelte
- Postgres/SQL
- Postgraphile/graphQL
A friend of mine asked me if I could help him with some boring paperwork stuff by creating a internal CRM for his medical office. I've decided to give it a shot. You can check the repo and see the internals if you don't have time for lenghy explanation or if you are intersted in details - read on. The project consists of a client list with personal data (like, dob, telephone etc.) and also a monthly medical intake data for each client like WBC, RBC, HBG, HCT etc.
Postgres
First thing first, we will start with creating a database in Postgres and defining tables for our usecase.
sudo -u postgres psql
CREATE USER admin WITH PASSWORD 'adminpassword';
CREATE DATABASE medical_db OWNER admin;
\c medical_db admin localhost 5432
CREATE TABLE clients (
client_id SERIAL PRIMARY KEY,
first_name text NOT NULL,
last_name text NOT NULL,
telephone text NOT NULL UNIQUE,
gender varchar(6) NOT NULL CHECK (gender IN ('MALE', 'FEMALE')),
active boolean NOT NULL DEFAULT TRUE,
dob date NOT NULL
);
CREATE TABLE intake (
intake_id SERIAL PRIMARY KEY,
client_id INTEGER NOT NULL REFERENCES clients (client_id),
weight decimal(4,1) NOT NULL,
wbc decimal(4,1) NOT NULL,
rbc decimal(4,1) NOT NULL,
hbg decimal(4,1) NOT NULL,
hct decimal(4,1) NOT NULL CHECK (hct >= 0 AND hct <= 100),
intake_time timestamp DEFAULT now()
);
If you dont know SQL, it is a shame. Even though it might not seem to be as fancy or cool like modern day ORMs, don't be decieved, because SQL is very very powerfull language. The fact is, the ORMs are not even 20% close to all the capabilities of SQL. Learn it. It can do magic.
Setting up Sapper
Now let's move on and create the basis for our Sapper app.
npx degit "sveltejs/sapper-template#rollup" medical-app
cd medical-app
npm install
We have just set up the default project. It comes with some boilerplate stuff that we won't need.
Get rid of all the default styles in /static/src/global.css
Also, delete all the files that are in /src/routes and /src/components folders.
Now, we won't style the project ourself. Will let this little file do it for us
<link rel="stylesheet" href="https://unpkg.com/mvp.css">
Just ad this to the head of the /src/template.html file
MVP.css is a minimalist CSS framework no class names at all. I usually use it to save time for quick prototype styling. I like it for it's simplicity and elegant design.
Setting up Postgraphile
Great. Now here comes my favorite part, Postgraphile.
This thing is a beast in itself and I totally recommend checking it out. What Postgraphile actually does, it reads the definition of the tables that we have created previously, and creates automagicaly queries and mutations so we don't have to write them manualy ourselves. Isn't that cool? Besides that, it provides us with a frontend UI that we will use later to check our queries. It is battle tested piece of software and has a very welcoming and great team behind it.
Let's install Postgraphile
npm i postgraphile
For queries and mutations I use awesome @urql client for Svelte. It is light and intuitive to work with.
npm i -D @urql/svelte graphql
In order to make postgraphile work, import into the /src/server.js file
import { postgraphile } from 'postgraphile';
and add Postgraphile as middleware for polka
...
polka()
.use(
postgraphile(
process.env.DATABASE_URL || "postgres://admin:adminpassword@localhost:5432/medical_db",
"public",
{
watchPg: true,
graphiql: true,
enhanceGraphiql: true,
}
),
compression({ threshold: 0 })
...
That's it.
Testing Postgraphile
Now if you start the server with
npm run dev
and visit the
localhost:3000/graphiql
route you sould be greeted with a graphiql, a web interface for queries and mutations that we talked about earlier.
Let's make sure queries and mutations are working properly.
We will fetch some non existent clients.
query MyQuery {
allClients {
nodes {
firstName
lastName
clientId
nodeId
telephone
dob
}
}
}
Abviously the result is an empty array since we haven't added any clients yet. Let's quickly add one.
Fetch it again and voila.
We got a graphql server working on Sapper with very little work. The only thing is left now is to create some interface so we can interact with our database using graphQL and lovely Svelte
Svelte & graphQL with @urql/svelte
We do not have any routes yet and no layout for them. So let's make some.
I have created the following routes:
- index.svelte
- /clients/newclient.svelte
- /clients/[slug]/index.svelte
- /clients/[slug]/intakes/newintake.svelte
- /clients/[slug]/intakes/[slug].svelte
Also in the /src/routes folder we need a layout.svelte file with a slot wrapped in the main tags
<script>
import Nav from "../components/Nav.svelte";
import { createClient, setClient, fetchExchange } from "@urql/svelte";
const client = createClient({
url: "http://localhost:3000/graphql",
exchanges: [fetchExchange],
});
setClient(client);
</script>
<Nav />
<main>
<slot></slot>
</main>
and the Nav.svelte in the components folder
<nav>
<ul>
<li>
<a href="/">Clients</a>
</li>
<li>
<a href="/clients/newclient">Add a client</a>
</li>
<li>
<a href="graphiql">Postgraphile</a>
</li>
</ul>
</nav>
Notice that we have also initialized the @urql svelte client pointing to our Postgraphile graphql endpoint in the layout page, so we can access it from any route.
The main index.svelte
<script>
import {
operationStore,
query,
} from "@urql/svelte";
import ClientsTable from "../components/ClientsTable.svelte";
const clients = operationStore(
`
query MyQuery {
allClients {
nodes {
clientId
firstName
lastName
active
telephone
intakesByClientId {
totalCount
}
}
}
}
`,
{ requestPolicy: "cache-first" }
);
query(clients);
const refresh = () => ($clients.context = { requestPolicy: "network-only" });
</script>
<svelte:head>
<title>Postgraphile Clients</title>
</svelte:head>
<section>
<header>
<h1>Client List</h1>
<button on:click={refresh}>refresh</button>
</header>
{#if $clients.fetching}
<h2>Loading...</h2>
{:else if $clients.error}
<h2>Oh no... {$clients.error.message}</h2>
{:else}
<ClientsTable clients={$clients?.data?.allClients?.nodes} />
{/if}
</section>
Creating a form for new client
In /clients/newclient.svelte lets import a component with a form for client creation
<script>
import ClientForm from "../../components/ClientForm.svelte";
</script>
<ClientForm />
And here is the actual component
<script>
import { mutation } from "@urql/svelte";
import { onMount } from "svelte";
import { goto } from "@sapper/app";
const createClientMutation = mutation({
query: `
mutation MyMutationCreate(
$firstName: String!,
$lastName: String!,
$telephone: String!,
$gender: String!,
$dob: Date!,
$active: Boolean!
) {
createClient(
input: {
client: {
firstName: $firstName
lastName: $lastName
telephone: $telephone
gender: $gender
dob: $dob,
active: $active
}
}
) {
client {
firstName
lastName
telephone
nodeId
clientId
active
}
}
}
`,
});
const updateClientMutation = mutation({
query: `
mutation MyMutationUpdate(
$id: Int!,
$firstName: String,
$lastName: String,
$telephone: String,
$gender: String,
$dob: Date,
$active: Boolean
) {
updateClientByClientId(
input: {
clientPatch: {
active: $active
clientId: $id
dob: $dob
gender: $gender
firstName: $firstName
lastName: $lastName
telephone: $telephone
}
clientId: $id
}
) {
client {
firstName
lastName
}
}
}
`,
});
let clt = {
firstName: "",
lastName: "",
telephone: "",
dob: "",
gender: "",
active: true,
};
export let originalClient;
const createNewClient = async () => {
const responseCreate = await createClientMutation({
...clt,
dob: new Date(clt.dob),
});
// console.log(responseCreate);
if (responseUpdate.data) {
goto("/");
}
};
const updateClient = async () => {
const responseUpdate = await updateClientMutation({
...clt,
id: originalClient.clientId,
dob: new Date(clt.dob),
});
// console.log(responseUpdate);
if (responseUpdate.data) {
goto("/");
}
};
const handleClient = async () => {
originalClient ? updateClient() : createNewClient();
};
onMount(() => {
if (originalClient) {
clt = Object.assign({}, originalClient);
}
});
</script>
<section>
<form on:submit|preventDefault={handleClient}>
<div class="grd">
<label for="fistName"
>First Name
<input type="text" name="firstName" required bind:value={clt.firstName} />
</label>
<label for="lastName"
>Last Name
<input type="text" name="lastName" required bind:value={clt.lastName} />
</label>
<label for="telephone"
>Telephone
<input type="tel" name="telephone" required bind:value={clt.telephone} />
</label>
<label for="dob"
>Date of Birth
<input type="date" name="dob" required bind:value={clt.dob} />
</label>
<label for="gender"
>Gender
<select name="gender" required bind:value={clt.gender}>
<option value="" disabled selected>-</option>
<option value="MALE">Male</option>
<option value="FEMALE">Female</option>
</select>
</label>
<label for="active"
>Active
<input type="checkbox" bind:checked={clt.active} />
</label>
</div>
<br />
{#if originalClient}
<button type="submit">Update client</button>
{:else}
<button type="submit">Add client</button>
{/if}
</form>
</section>
<style>
.grd {
display: grid;
grid-template-columns: 1fr 1fr;
}
</style>
If you pay attention we have here to mutaions, create and update client. This is because we are reusing the same form for both cases.
Add the table of clients
Here is ClientsTable.svelte component that we are importing in the main index.svelte. We are expecting an array of clients. We loop thru it via #each clients as client and display desired data
<script>
export let clients = [];
</script>
<table>
<tr>
<th>Name</th>
<th>Status</th>
<th>Phone number</th>
<th>Intakes</th>
<th />
</tr>
{#each clients as client (client.clientId)}
<tr>
<td>{client?.firstName} {client?.lastName}</td>
<td>{client?.active ? "ACTIVE" : "INACTIVE"}</td>
<td><a href={`tel:${client?.telephone}`}>{client?.telephone}</a></td>
<td>{client?.intakesByClientId?.totalCount}</td>
<td><a href={`clients/${client.clientId}`}><button>More</button></a></td>
</tr>
{/each}
</table>
<style>
table {
margin: 0 auto;
}
</style>
Edit/Slug page of a client
<script>
import ClientForm from "../../../components/ClientForm.svelte";
import { stores } from "@sapper/app";
const { page } = stores();
import { operationStore, query } from "@urql/svelte";
import { onMount } from "svelte";
import IntakeTable from "../../../components/IntakeTable.svelte";
const client = operationStore(
`
query MyQuery($id: Int!) {
clientByClientId(clientId: $id) {
firstName
lastName
dob
gender
telephone
active
clientId
intakesByClientId {
nodes {
intakeId
intakeTime
rbc
}
}
}
}
`,
{ id: parseInt($page.params.slug) },
{ requestPolicy: "cache-first" }
);
query(client);
const refresh = () => ($client.context = { requestPolicy: "network-only" });
onMount(() => {
refresh();
});
$: clientIntakes = $client.data?.clientByClientId.intakesByClientId?.nodes
</script>
<div class="rght">
<a href={`/clients/${$page.params.slug}/intakes/newintake`}
><button>New Intake</button></a
>
</div>
{#if $client.fetching}
<h2>Loading...</h2>
{:else if $client.error}
<h2>Oh no... {$client.error.message}</h2>
{:else}
<div class="flx">
<ClientForm originalClient={$client.data?.clientByClientId} />
<IntakeTable {clientIntakes} clientId={$page.params.slug}/>
</div>
{/if}
<style>
.rght {
text-align: right;
}
</style>
We are reusing the ClientForm for editing. Notice how we pass the slug into the client query as id.
New form for medical data input
In our newintake route we are adding IntakeForm.svelte component and pass to it the clientId from page.params.slug
here is the component itself
<script>
import { mutation } from "@urql/svelte";
import { goto } from "@sapper/app";
export let clientId;
let intakeData = {
wbc: "",
rbc: "",
hbg: "",
hct: "",
weight: "",
};
const createIntakeMutation = mutation({
query: `
mutation MyMutation($id: Int!, $wbc: BigFloat!, $rbc: BigFloat!, $hbg: BigFloat!, $hct: BigFloat!, $weight: BigFloat!) {
createIntake(
input: {intake: {clientId: $id, wbc: $wbc, rbc: $rbc, hbg: $hbg, hct: $hct, weight: $weight}}
) {
intake {
intakeId
}
}
}`,
});
const createNewIntake = async () => {
const responseCreate = await createIntakeMutation({
id: parseInt(clientId),
...intakeData,
});
// console.log(responseCreate);
if (responseCreate.data) {
goto(`/clients/${clientId}`);
}
};
</script>
<section>
<h1>New Intake</h1>
<form on:submit|preventDefault={createNewIntake}>
<label for="wbc"
>White blood cells
<input type="number" name="wbc" step="0.1" required bind:value={intakeData.wbc} />
</label>
<label for="rbc"
>Red blood cells
<input type="number" name="rbc" step="0.1" required bind:value={intakeData.rbc} />
</label>
<label for="hbg"
>HBG
<input type="number" name="hbg" step="0.1" required bind:value={intakeData.hbg} />
</label>
<label for="hct"
>HCT
<input
type="number"
name="hct"
required
step="0.1"
bind:value={intakeData.hct}
min="0"
max="100"
/>
</label>
<label for="weight"
>Weight
<input type="number" step="0.1" name="weight" bind:value={intakeData.weight} />
</label>
<button type="submit">Send</button>
</form>
</section>
<style>
section {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: center;
align-items: center;
}
</style>
It is intersting to note that the mutation here for the variables is set to BigInt since we defined them in the intake schema as decimal. We have set the input to step="0.1"
And that is it. I hope you have learned something new. If you have any suggestions and know how to improve the code, please hit me up. I will be glad to hear from you. It was quite easy to make a complete, full stack app with Sapper. Postgraphile here does the whole magic under the hood. I would like to thank Bengie for it. Cheers!
Top comments (2)
Hi Denis,
sorry unrelated to this post but I haven't found another way to reach out to you. You starred my inlang repo on github. I wonder what caught your interest/how to improve the product. It would be awesome if you can comment in the github discussion github.com/samuelstroschein/inlang....
Best,
Samuel :)
I did =)