You will learn,
- How to get the schema from the backend?
- How to use a code generation tool with Typescript?
- How to make a GraphQL API request?
- How to handle expired JWT token?
Lets dive in!
Tools we will be using:
urql
This is the GraphQL client we will be using to make the API request possible.
There are other alternatives like houdini
and apollo-client
but after struggling with them I found urql package to be much better.
urql/exchange-auth
This package or an addon acts like the request interceptor which will help us in renewing the expired token.
graphql-codegen/cli & graphql-codegen/client-preset
This is used for codegen for a typescript project.
Additionally, we will be using:
- Tailwindcss — For CSS styling.
- jwt-decode — To extract information stored in the JWT token.
Files and Folder structure
src
- lib
- components
- graphql
- models
- services
- utils
- index.ts
- routes
- login
- stores
app.css
app.d.ts
app.html
codegen.ts
vite.config.ts
tailwind.config.js
Create a Svelte project with Typescript.
Choose options below after running the command:
- A Skeleton project
- Yes, using TypeScript syntax
- Add Prettier for code formatting
npm create svelte@latest your_app_name
Installing dependencies
{
"name": "svelte-app",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"codegen": "graphql-codegen",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@graphql-codegen/cli": "^5.0.0",
"@graphql-codegen/client-preset": "^4.1.0",
"@sveltejs/adapter-auto": "^2.1.1",
"@sveltejs/kit": "^1.27.6",
"@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-svelte": "^2.35.1",
"graphql": "^16.8.1",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.1.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.2",
"tailwindcss": "^3.3.5",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.0.2"
},
"type": "module",
"dependencies": {
"@urql/exchange-auth": "^2.1.6",
"@urql/svelte": "^4.0.4",
"jwt-decode": "^4.0.0",
"svelte-use-form": "^2.10.0"
}
}
Configuring code generation (for Typescript)
Create a codegen.ts file in the root of the project and add the following content.
Replace base url on the schema
property below. Depending on the env introspection can be enabled or disabled in the backend. By default introspection should be enabled.
// codegen.ts
import { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'http://localhost:8000/graphql',
documents: ['src/**/*.svelte', 'src/**/*.ts'],
ignoreNoDocuments: true,
generates: {
'./src/lib/graphql/': { // Folder to store downloaded GrapqhQL Schema.
preset: 'client',
plugins: [],
},
},
};
export default config;
Configuring Tailwindcss
Create tailwind.config.js in the root of your project and add the following content.
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {},
plugins: []
};
Now import tailwindcss in the src/app.css file.
* src/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Configuring Storage for Auth Tokens
Create local-storage.service.ts to store the auth tokens.
We are using try-catch block to prevent errors when Svelte tries to access localStorage in the server. As localStorage is only available in the browser.
// src/lib/services/local-storage.service.ts
export const accessTokenKey = "access_token";
export const refreshTokenKey = "refresh_token";
export function saveAuthTokens(accessToken: string, refreshToken: string) {
localStorage.setItem(accessTokenKey, accessToken);
localStorage.setItem(refreshTokenKey, refreshToken);
}
export function getAccessToken(): string | null {
try {
return localStorage.getItem(accessTokenKey);
} catch {
return null;
}
}
export function getRefreshToken(): string | null {
try {
return localStorage.getItem(refreshTokenKey);
} catch {
return null;
}
}
export function saveNewAccessToken(newToken: string) {
localStorage.setItem(accessTokenKey, newToken);
}
export function clearAuthTokens(): void {
localStorage.clear()
}
Dowloading our API schema from the backend
Recheck package.json file above if you encounter any issues here.
yarn codegen
Adding our GraphQL endpoints
Get the Auth Queries defined in the backend.
// src/lib/graphql/queries/auth.ts
import { gql } from "@urql/svelte"
import type { AuthMutationsRenewTokenArgs, AuthQueriesLoginArgs, LoginSuccess, LogoutSuccess, RenewTokenSuccess } from "../graphql"
export const LOGIN_QUERY = gql<{ auth: { login: LoginSuccess } }, AuthQueriesLoginArgs>`
query Login($input: LoginInput!) {
auth {
login(input: $input) {
... on LoginSuccess {
__typename
accessToken
refreshToken
}
}
}
}
`
export const RENEW_TOKEN_MUTATION = gql<{ auth: { renewToken: RenewTokenSuccess } }, AuthMutationsRenewTokenArgs>`
mutation RenewAccessToken($input: RenewTokenInput!) {
auth {
renewToken(input: $input) {
... on RenewTokenSuccess {
__typename
newAccessToken
}
}
}
}
`
export const LOGOUT_MUTATION = gql<{ auth: { logout: LogoutSuccess } }, { accessToken: string }>`
mutation Logout($accessToken: String!) {
auth {
logout(accessToken: $accessToken) {
... on LogoutSuccess {
__typename
success
}
}
}
}
`
Configuring Urql client
Update vite.config.ts as shown below.
// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [sveltekit()],
optimizeDeps: {
exclude: ['@urql/svelte'],
}
});
Now add the base.service.ts file to configure the Urql client.
This is also where we will renew the access-token when required.
import { goto } from "$app/navigation";
import { jwtDecode } from "jwt-decode";
import { dev } from "$app/environment";
import { Client, cacheExchange, fetchExchange } from "@urql/svelte";
import { authExchange, type AuthUtilities } from "@urql/exchange-auth";
import { RENEW_TOKEN_MUTATION } from "$lib/graphql/queries/auth";
import { clearAuthTokens, getAccessToken, getRefreshToken, saveNewAccessToken } from "./local-storage.service";
const auth = authExchange(async (utilities: AuthUtilities) => {
let token = getAccessToken();
let refreshToken = getRefreshToken();
return {
addAuthToOperation(operation) {
return token
? utilities.appendHeaders(operation, {
Authorization: `Bearer ${token}`,
})
: operation;
},
didAuthError(error) {
// Check for "Forbidden" error response, caused when access token has expired.
return error.response?.status === 403;
},
willAuthError() {
// Sync tokens on every operation
token = getAccessToken();
refreshToken = getRefreshToken();
if (token) {
// If JWT has expired then run the refreshAuth func.
const { exp } = jwtDecode(token);
if (Date.now() >= exp! * 1000) return true;
}
return false;
},
async refreshAuth() {
// Clear the token for refresh token API to be called without any issues.
// addAuthToOperation will fail if token is not set to null.
token = null;
if (refreshToken) {
try {
const result = await utilities.mutate(RENEW_TOKEN_MUTATION, {
input: { refreshToken },
});
if (result.error) {
clearAuthTokens();
goto("/login");
} else if (result.data?.auth.renewToken.newAccessToken) {
const renewedToken = result.data.auth.renewToken.newAccessToken;
token = renewedToken;
saveNewAccessToken(renewedToken);
}
} catch (e) {
console.log("Refresh Token Error", e);
}
}
},
};
});
const gqlClient = new Client({
requestPolicy: "network-only",
exchanges: [cacheExchange, auth, fetchExchange],
url: dev ? "http://localhost:8000/graphql" : "<your_production_url>",
});
export default gqlClient;
Now initialize the gqlClient we created above in our app.
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { setContextClient } from '@urql/svelte';
import gqlClient from '../lib/services/base.service';
import { getAccessToken } from '$lib/services/local-storage.service';
onMount(() => {
const accessToken = getAccessToken();
const visitingRoute = location.pathname;
if (!accessToken) return goto(`/login`);
if (visitingRoute === '/') return goto(`/dashboard`);
});
setContextClient(gqlClient);
</script>
<p>loading...</p>
Create the auth service file
// src/lib/services/auth.service.ts
import gqlClient from "./base.service";
import type { LoginSuccess } from "$lib/graphql/graphql";
import { LOGIN_QUERY, LOGOUT_MUTATION } from "$lib/graphql/queries/auth";
const login = async (identifier: string, password: string): Promise<LoginSuccess | undefined> => {
const result = await gqlClient.query(LOGIN_QUERY, { input: { identifier, password } }).toPromise();
if (result.error) throw result.error;
return result.data!.auth.login;
}
const logout = async (accessToken: string) => {
const result = await gqlClient.mutation(LOGOUT_MUTATION, { accessToken }).toPromise();
if (result.error) throw result.error;
return result.data;
}
const authService = {
login,
logout,
}
export default authService;
Creating our login page
<!-- src/routes/login/+layout.svelte -->
<div class="flex flex-col flex-1 bg-white justify-center">
<div class="m-10 flex flex-grow flex-col rounded-2xl items-center p-5">
<slot />
</div>
</div>
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import { goto } from '$app/navigation';
import { useForm, Hint, validators, email, required, HintGroup } from 'svelte-use-form';
import authService from '$lib/services/auth.service';
import { notifierStore } from '../../stores/notifier.store';
import { saveAuthTokens } from '$lib/services/local-storage.service';
import LoadingButtonXL from '$lib/components/shared/loading-button-xl.svelte';
const form = useForm();
let password: string;
let userEmail: string;
let isLoading = false;
async function onLogin() {
if (isLoading) return;
try {
isLoading = true;
const data = await authService.login(userEmail, password);
saveAuthTokens(data!.accessToken, data!.refreshToken);
goto('/dashboard');
} catch (e) {
handleError(e);
} finally {
isLoading = false;
}
}
function onForgotPassword() {}
function handleError(error: any) {
if (typeof error === 'string') {
console.log(error);
} else if (error instanceof Error) {
console.log(error.message);
}
}
</script>
<div class="lg:w-9/12 w-full flex flex-col lg:mt-52 text-gray-500">
<h1 class="text-4xl text-black mb-10 text-center">Login to Continue</h1>
<form
use:form
class="w-full flex flex-col"
on:submit|preventDefault={$form.valid ? onLogin : null}
>
<input
type="email"
name="email"
placeholder="Email"
bind:value={userEmail}
use:validators={[required, email]}
class="border border-gray-200 rounded-2xl py-5 px-6 text-xl tracking-wide text-gray-600"
/>
<HintGroup for="email">
<Hint class="text-red-800 m-2" on="required">This is a mandatory field.</Hint>
<Hint class="text-red-800 m-2" on="email" hideWhenRequired>Email is not valid.</Hint>
</HintGroup>
<input
type="password"
name="password"
bind:value={password}
placeholder="Password"
use:validators={[required]}
class="border border-gray-200 rounded-2xl py-5 px-6 mt-4 text-xl tracking-wide text-gray-600"
/>
<HintGroup for="password">
<Hint class="text-red-800 m-2" on="required">This is a mandatory field.</Hint>
</HintGroup>
<button class="text-MD mt-2 self-end" on:click={onForgotPassword}>Forgot Password?</button>
<button
type="submit"
class="bg-app-primary flex flex-row items-center justify-center text-white my-5 rounded-2xl p-4"
>
{#if isLoading}
your_loading_spinner
{/if}
<span class="ml-2">LOGIN</span>
</button>
</form>
</div>
That should be it! If any questions do not hesitate to comment. Thanks.