It's been a while since I last updated this series of articles. I have been away, and I sincerely apologize for the abandonment. I will be completing the series by going through the frontend code and other updates I made at the backend. Let's get into it!
Source code
The source code for this series is hosted on GitHub via:
A Q&A web application to demostrate how to build a secured and scalable client-server application with axum and sveltekit
CryptoFlow
CryptoFlow is a full-stack web application built with Axum and SvelteKit. It's a Q&A system tailored towards the world of cryptocurrency!
I also have the application live. You can interact with it here. Please note that the backend was deployed on Render which:
Spins down a Free web service that goes 15 minutes without receiving inbound traffic. Render spins the service back up whenever it next receives a request to process. Spinning up a service takes up to a minute, which causes a noticeable delay for incoming requests until the service is back up and running. For example, a browser page load will hang temporarily.
Its building process is explained in this series of articles.
I also have the application live. You can interact with it here. Please note that the backend was deployed on Render which:
Spins down a Free web service that goes 15 minutes without receiving inbound traffic. Render spins the service back up whenever it next receives a request to process. Spinning up a service takes up to a minute, which causes a noticeable delay for incoming requests until the service is back up and running. For example, a browser page load will hang temporarily.
Implementation
Step 1: Landing page
Our application will have a landing page where questions are listed. The page will be split into three resizable columns:
Left column: This will contain developer information and some coins with their rankings. The number of coins at a time will be specified by the NUM_OF_COINS_TO_SHOW constant which is 10 by default but can be made configurable. Every 10 seconds, the list will change.
Middle column: A list of questions will be housed here.
Right column: We will have some charts for plotting the prices, market caps, and total volumes of any selected coin. Making a total of three multi-line charts. Each line corresponds to each of the coins. We will provide users with two inputs where they can select up to 4 coins at a time, and the number of days they want their histories to be shown.
These entire requirements are implemented in frontend/src/routes/+page.svelte:
<script>importChartsfrom"$lib/components/Charts.svelte";import{NUM_OF_COINS_TO_SHOW}from"$lib/utils/constants.js";import{onDestroy,onMount}from"svelte";exportletdata,/** @type {import('./$types').ActionData} */form;/**
* @typedef {Object} Coin
* @property {string} id - The id of the coin.
* @property {string} name - The name of the coin.
* @property {string} symbol - The symbol of the coin.
* @property {string} image - The image of the coin.
* @property {number} market_cap_rank - The market cap rank of the coin.
*//**
* @type {Coin[]}
*/letselectedCoins=[],/** @type {Number} */intervalId;$:({questions,coins}=data);constselectCoins=()=>{constselectedCoinsSet=newSet();while(selectedCoinsSet.size<NUM_OF_COINS_TO_SHOW){constrandomIndex=Math.floor(Math.random()*coins.length);selectedCoinsSet.add(coins[randomIndex]);}selectedCoins=Array.from(selectedCoinsSet);};onMount(()=>{selectCoins();// Select coins immediately on mountintervalId=setInterval(selectCoins,10000);// Select coins every 10 seconds});onDestroy(()=>{clearInterval(intervalId);// Clear the interval when the component is destroyed});</script><divclass="flex flex-col md:flex-row text-[#efefef]"><!-- Left Column for Tags --><divclass="hidden md:block md:w-1/4 p-4 resize overflow-auto"><!-- Developer Profile Card --><divclass="bg-[#041014] hover:bg-black border border-black hover:border-[#145369] rounded-lg shadow p-4 mb-1"><imgsrc="https://media.licdn.com/dms/image/D4D03AQElygM4We8kqA/profile-displayphoto-shrink_800_800/0/1681662853733?e=1721865600&v=beta&t=idb1YHHzZbXHJ1MxC4Ol2ZnnbyCHq6GDtjzTzGkziLQ"alt="Developer"class="rounded-full w-24 h-24 mx-auto mb-3"/><h3class="text-center text-xl font-bold mb-2">John O. Idogun</h3><ahref="https://github.com/sirneij"class="text-center text-blue-500 block mb-2">
@SirNeij
</a><pclass="text-center">Developer & Creator of CryptoFlow</p></div><divclass="bg-[#041014] p-6 rounded-lg shadow mb-6 hover:bg-black border border-black hover:border-[#145369]"><h2class="text-xl font-semibold mb-4">Coin ranks</h2>
{#each selectedCoins as coin (coin.id)}
<divclass="flex items-center justify-between mb-2 border-b border-[#0a0a0a] hover:bg-[#041014] px-3 py-1"><divclass="flex items-center"><imgclass="w-8 h-8 rounded-full mr-2 transition-transform duration-500 ease-in-out transform hover:rotate-180"src="{coin.image}"alt="{coin.name}"/><spanclass="mr-2">{coin.name}</span></div><spanclass="inline-block bg-blue-500 text-white text-xs px-2 rounded-full uppercase font-semibold tracking-wide">
#{coin.market_cap_rank}
</span></div>
{/each}
</div></div><divclass="md:w-5/12 py-4 px-2 resize overflow-auto">
{#if questions} {#each questions as question (question.id)}
<divclass="
bg-[#041014] mb-1 rounded-lg shadow hover:bg-black border border-black hover:border-[#145369]"><divclass="p-4"><ahref="/questions/{question.id}"class="text-xl font-semibold hover:text-[#2596be]">
{question.title}
</a><!-- <p class="mt-2">{article.description}</p> --><divclass="mt-3 flex flex-wrap">
{#each question.tags as tag}
<spanclass="mr-2 mb-2 px-3 py-1 text-sm bg-[#041014] border border-[#145369] hover:border-[#2596be] rounded">
{tag.name}
</span>
{/each}
</div></div></div>
{/each} {/if}
</div><!-- Right Column for Charts --><divclass="hidden md:block md:w-1/3 px-2 py-4 resize overflow-auto"><divclass="bg-[#041014] rounded-lg shadow p-4 hover:bg-black border border-black hover:border-[#145369]"><h2class="text-xl font-semibold mb-4">Charts</h2><Charts{coins}{form}/></div></div></div>
To select 10 unique coins every 10 seconds, we randomly get them from the coins data and use Set to ensure no duplication is permitted. This is what selectCoins is about. As the DOM gets loaded, we call this function and then use setInterval for the periodic and automatic selection. We also ensure the interval is destroyed when we navigate out of the page for memory safety reasons.
For the charts, there is a component, Charts, that handles the logic:
<!-- frontend/src/lib/components/Charts.svelte --><script>import{applyAction,enhance}from'$app/forms';import{notification}from'$lib/stores/notification.store';importShowErrorfrom'./ShowError.svelte';importLoaderfrom'./Loader.svelte';import{fly}from'svelte/transition';import{onMount}from'svelte';importChartfrom'chart.js/auto';import'chartjs-adapter-moment';import{chartConfig,handleZoom}from'$lib/utils/helpers';importTagCoinfrom'./inputs/TagCoin.svelte';exportletcoins,/** @type {import('../../routes/$types').ActionData} */form;/** @type {HTMLInputElement} */lettagInput,/** @type {HTMLCanvasElement} */priceChartContainer,/** @type {HTMLCanvasElement} */marketCapChartContainer,/** @type {HTMLCanvasElement} */totalVolumeChartContainer,fetching=false,rendered=false,/**
* @typedef {Object} CryptoData
* @property {Array<Number>} prices - The price data
* @property {Array<Number>} market_caps - The market cap data
* @property {Array<Number>} total_volumes - The total volume data
*//**
* @typedef {Object.<String, CryptoData>} CryptoDataSet
*//** @type {CryptoDataSet} */plotData={},/** @type {CanvasRenderingContext2D | null} */context,/** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */priceChart,/** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */marketCapChart,/** @type {Chart<"line", { x: Date; y: number; }[], unknown>} */totalVolumeChart,/** @type {CanvasRenderingContext2D|null} */priceContext,/** @type {CanvasRenderingContext2D|null} */marketCapContext,/** @type {CanvasRenderingContext2D|null} */totalVolumeContext;/** @type {import('../../routes/$types').SubmitFunction}*/consthandleCoinDataFetch=async ()=>{fetching=true;returnasync ({result})=>{fetching=false;if (result.type==='success'){$notification={message:'Coin data fetched successfully',colorName:'blue'};if (result.data){plotData=result.data.marketData;awaitapplyAction(result);}}};};onMount(()=>{priceContext=priceChartContainer.getContext('2d');marketCapContext=marketCapChartContainer.getContext('2d');totalVolumeContext=totalVolumeChartContainer.getContext('2d');if (priceContext===null||marketCapContext===null||totalVolumeContext===null){thrownewError('Could not get the context of the canvas element');}// Create a new configuration object for each chartconstpriceChartConfig={...chartConfig};priceChartConfig.data={datasets:[]};priceChart=newChart(priceContext,priceChartConfig);constmarketCapChartConfig={...chartConfig};marketCapChartConfig.data={datasets:[]};marketCapChart=newChart(marketCapContext,marketCapChartConfig);consttotalVolumeChartConfig={...chartConfig};totalVolumeChartConfig.data={datasets:[]};totalVolumeChart=newChart(totalVolumeContext,totalVolumeChartConfig);rendered=true;// Add event listeners for zoomingpriceChartContainer.addEventListener('wheel',(event)=>handleZoom(event,priceChart));marketCapChartContainer.addEventListener('wheel',(event)=>handleZoom(event,marketCapChart));totalVolumeChartContainer.addEventListener('wheel',(event)=>handleZoom(event,totalVolumeChart));});/**
* Update the chart with new data
* @param {Chart<"line", { x: Date; y: number; }[], unknown>} chart - The chart to update
* @param {Array<Array<number>>} data - The new data to update the chart with
* @param {string} label - The label to use for the dataset
* @param {string} cryptoName - The name of the cryptocurrency
*/constupdateChart=(chart,data,label,cryptoName)=>{constdataset={label:`${cryptoName}${label}`,data:data.map(/** @param {Array<number>} item */(item)=>{return{x:newDate(item[0]),y:item[1]};}),fill:false,borderColor:'#'+Math.floor(Math.random()*16777215).toString(16),tension:0.1};chart.data.datasets.push(dataset);chart.update();};$:if (rendered){// Clear the datasets for each chartpriceChart.data.datasets=[];marketCapChart.data.datasets=[];totalVolumeChart.data.datasets=[];Object.keys(plotData).forEach(/** @param {string} cryptoName */(cryptoName)=>{// Update each chart with the new dataupdateChart(priceChart,plotData[cryptoName].prices,'Price',cryptoName);updateChart(marketCapChart,plotData[cryptoName].market_caps,'Market Cap',cryptoName);updateChart(totalVolumeChart,plotData[cryptoName].total_volumes,'Total Volume',cryptoName);});}</script><formaction="?/getCoinData"method="POST"use:enhance={handleCoinDataFetch}><ShowError{form}/><divstyle="display: flex; justify-content: space-between;"><divstyle="flex: 2; margin-right: 10px;"><TagCoinlabel="Cryptocurrencies"id="tag-input"name="tags"value=""{coins}placeholder="Select cryptocurrencies..."/></div><divstyle="flex: 1; margin-left: 10px;"><labelfor="days"class="block text-[#efefef] text-sm font-bold mb-2">Days</label><inputtype="number"id="days"name="days"value="7"requiredclass="w-full p-4 bg-[#0a0a0a] text-[#efefef] border border-[#145369] rounded focus:outline-none focus:border-[#2596be] text-gray-500"placeholder="Enter days"/></div></div>
{#if fetching}
<Loaderwidth={20}message="Fetching data..."/>
{:else}
<buttonclass="px-6 py-2 bg-[#041014] border border-[#145369] hover:border-[#2596be] text-[#efefef] hover:text-white rounded">
Fetch Coin Data
</button>
{/if}
</form><divin:fly={{x:100,duration:1000,delay:1000}}out:fly={{duration:1000}}><canvasbind:this={priceChartContainer}/><canvasbind:this={marketCapChartContainer}/><canvasbind:this={totalVolumeChartContainer}/></div>
We employed Charts.js as the charting library. It's largely simple to use. Though the component looks big, it's very straightforward. We used JSDocs instead of TypeScript for annotations. At first, when the DOM was mounted, we created charts with empty datasets. We then expect users to select their preferred coins and number of days. Clicking the Fetch Coin Data button will send the inputted data to the backend using SvelteKit's form actions. The data returned by this API call will be used to populate the plots using Svelte's reactive block dynamically. The code for the form action and the preliminary data retrieval from the backend is in frontend/src/routes/+page.server.js:
import{BASE_API_URI}from"$lib/utils/constants";import{fail}from"@sveltejs/kit";/** @type {import('./$types').PageServerLoad} */exportasyncfunctionload({fetch}){constfetchQuestions=async ()=>{constres=awaitfetch(`${BASE_API_URI}/qa/questions`);returnres.ok&&(awaitres.json());};constfetchCoins=async ()=>{constres=awaitfetch(`${BASE_API_URI}/crypto/coins`);returnres.ok&&(awaitres.json());};constquestions=awaitfetchQuestions();constcoins=awaitfetchCoins();return{questions,coins,};}// Get coin data form action/** @type {import('./$types').Actions} */exportconstactions={/**
* Get coin market history data from the API
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @returns Error data or redirects user to the home page or the previous page
*/getCoinData:async ({request,fetch})=>{constdata=awaitrequest.formData();constcoinIDs=String(data.get("tags"));constdays=Number(data.get("days"));constres=awaitfetch(`${BASE_API_URI}/crypto/coin_prices?tags=${coinIDs}¤cy=USD&days=${days}`);if (!res.ok){constresponse=awaitres.json();consterrors=[{id:1,message:response.message}];returnfail(400,{errors:errors});}constresponse=awaitres.json();return{status:200,marketData:response,};},};
The endpoint used here, ${BASE_API_URI}/crypto/coin_prices?tags=${coinIDs}¤cy=USD&days=${days}, was just created and the code is:
// backend/src/routes/crypto/prices.rsusecrate::{settings,utils::{CustomAppError,CustomAppJson},};useaxum::extract::Query;usestd::collections::HashMap;#[derive(serde::Deserialize,Debug)]pubstructCoinMarketDataRequest{tags:String,currency:String,days:i32,}#[derive(serde::Deserialize,Debug,serde::Serialize)]pubstructCoinMarketData{prices:Vec<Vec<f64>>,market_caps:Vec<Vec<f64>>,total_volumes:Vec<Vec<f64>>,}#[axum::debug_handler]#[tracing::instrument(name="get_coin_market_data")]pubasyncfnget_coin_market_data(Query(coin_req):Query<CoinMarketDataRequest>,)->Result<CustomAppJson<HashMap<String,CoinMarketData>>,CustomAppError>{lettag_ids:Vec<String>=coin_req.tags.split(',').map(|s|s.to_string()).collect();letmutresponses=HashMap::new();letsettings=settings::get_settings().expect("Failed to get settings");fortag_idintag_ids{leturl=format!("{}/coins/{}/market_chart?vs_currency={}&days={}",settings.coingecko.api_url,&tag_id,coin_req.currency,coin_req.days);matchreqwest::get(&url).await{Ok(response)=>matchresponse.json::<CoinMarketData>().await{Ok(data)=>{responses.insert(tag_id,data);}Err(e)=>{tracing::error!("Failed to parse market data from response: {}",e);}},Err(e)=>{tracing::error!("Failed to fetch market data from CoinGecko: {}",e);}}}Ok(CustomAppJson(responses))}
It simply uses CoinGecko's API to retrieve the history data of the coins since days ago. Back to the frontend code, SvelteKit version 2 made some changes that mandate explicitly awaiting asynchronous functions in load. This and other changes will be pointed out as the series progresses. Our load fetches both the questions and coins from the backend. No pagination is implemented here but it's easy to implement with sqlx. Pagination can also be done easily with sveltekit. You can take that up as a challenge.
The Charts.svelte components used some custom input components. This is simply for modularity's sake and is just simple HTML elements with tailwind CSS. Also, it used chartConfig and handleZoom. The former is just a simple configuration for the entire charts while the latter just allows simple zoom in and out of the plots. For better zooming and panning features, it's recommended to use the chartjs-plugin-zoom.
With all these in place, the landing page should look like this:
Step 2: Question Detail page
The middle column on the landing page shows all the questions in the database. We need a page that zooms in on each question so that other users can provide answers. We have such a page in frontend/src/routes/questions/[id]/+page.svelte:
The first shows the question and all the answers to that question.
The second shows the current price of the coin tagged in the question. The prices do not get updated live or in real time, you need to refresh the page for updated prices but this can be improved using web sockets.
This page has an accompanying +page.server.js that fetches the data the page uses and handles other subsequent interactions such as posting, updating, and deleting answers:
import{BASE_API_URI}from"$lib/utils/constants";import{fail}from"@sveltejs/kit";/** @type {import('./$types').PageServerLoad} */exportasyncfunctionload({fetch,params}){constfetchQuestion=async ()=>{constres=awaitfetch(`${BASE_API_URI}/qa/questions/${params.id}`);returnres.ok&&(awaitres.json());};constfetchAnswers=async ()=>{constres=awaitfetch(`${BASE_API_URI}/qa/questions/${params.id}/answers`);returnres.ok&&(awaitres.json());};return{question:awaitfetchQuestion(),answers:awaitfetchAnswers(),};}/** @type {import('./$types').Actions} */exportconstactions={/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @returns Error data or redirects user to the home page or the previous page
*/answer:async ({request,fetch,params,cookies})=>{constdata=awaitrequest.formData();constcontent=String(data.get("content"));/** @type {RequestInit} */constrequestInitOptions={method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Cookie:`sessionid=${cookies.get("cryptoflow-sessionid")}`,},body:JSON.stringify({content:content,}),};constres=awaitfetch(`${BASE_API_URI}/qa/answer/${params.id}`,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors=[{id:1,message:response.message}];returnfail(400,{errors:errors});}constresponse=awaitres.json();return{status:200,answer:response,};},/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @returns Error data or redirects user to the home page or the previous page
*/deleteAnswer:async ({request,fetch,cookies})=>{constdata=awaitrequest.formData();constanswerID=String(data.get("answerID"));/** @type {RequestInit} */constrequestInitOptions={method:"DELETE",credentials:"include",headers:{"Content-Type":"application/json",Cookie:`sessionid=${cookies.get("cryptoflow-sessionid")}`,},};constres=awaitfetch(`${BASE_API_URI}/qa/answers/${answerID}`,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors=[{id:1,message:response.message}];returnfail(400,{errors:errors});}return{status:res.status,};},/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @returns Error data or redirects user to the home page or the previous page
*/updateAnswer:async ({request,fetch,cookies})=>{constdata=awaitrequest.formData();constanswerID=String(data.get("answerID"));constcontent=String(data.get("content"));/** @type {RequestInit} */constrequestInitOptions={method:"PATCH",credentials:"include",headers:{"Content-Type":"application/json",Cookie:`sessionid=${cookies.get("cryptoflow-sessionid")}`,},body:JSON.stringify({content:content,}),};constres=awaitfetch(`${BASE_API_URI}/qa/answers/${answerID}`,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors=[{id:1,message:response.message}];returnfail(400,{errors:errors});}return{status:res.status,answer:awaitres.json(),};},};
It's just the familiar structure with a load function and a bunch of other form actions. Since all the other pages have this structure, I will skip explaining them but will include their screenshots
You can follow along by reading through the code on GitHub. They are very easy to follow.
The question detail page looks like this:
As for login and signup pages, we have these:
When one registers, a one-time token is sent to the user's email. There's a page to input this token and get the account attached to it activated. The page looks like this:
With that, we end this series. Kindly check the series' GitHub repository for the updated and complete code. They are intuitive.
I apologize once again for the abandonment.
Outro
Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!