Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

ryanfiller
ryanfiller

Posted on • Originally published atryanfiller.com on

     

Svelte, Sanity, and Severless Functions

Last month I wrote about creating anNPM package with Svelte. This month I built a basicCRUD site that would let other people use thecolor-contrast-table package tool I made.

The sitemap for the project was pretty basic.

HOMEPAGE - A list of all the site's users - New Users can be created here.  └─ /OWNER    - A user's list of palettes that they own.   - New Palettes can be created here.    └─ /PALETTE     - A specific color palette that contains an array of color data.     - New Colors can be created here.     - A user can go into edit mode to live edit and save existing colors.     - A user can view and edit JSON for the color data.
Enter fullscreen modeExit fullscreen mode

For the final product, check out the code onGitHub or thelive site.

The Tech Stack

Svelte

Because I wrote the same NPM package in three frameworks, I had three options to choose from for this project. I've mentioned in several previous posts that I'm interested in learning more aboutSvelte. Svelte is also a great fit for this project becase, as their homepage says Svelte is -

Truly reactive. No more complex state management libraries — Svelte brings reactivity to JavaScript itself.

Built-in, easy reactivity is ideal since the main feature of this site are users changing color values and viewing information in real time.

a user using the edit feature on the site

Routify

Because I already usedSapper on a few other projects, I started this one out using it too. However, I quickly ran into a big issue.

Sapper works best in two scenarios:

  • as a node.js app running on a server and dynamically generating pages
  • as a statically served site with a predetermined URL structure

Serving the site statically meant that I would need to trigger a build and a re-deploy any time a user added any new data. While it is possible to deploy a Sapper project in Node-mode toVercel, I wanted to host this site on Netlify to avoid trying too many new things at once. Maybe next time I'll give Vercel a shot.

Luckily,Routify exists as an entirely client-side routing solution for Svelte apps. This means that the site could still deploy statically to Netlify, and instead of running a Vercel server to create dynamic pages, I could do all the work in a user's browser. As a bonus, Routify has a very similar API to Sapper so it wasn't a huge lift to refactor.

Routify has three main concepts.

File Structure

Routify and Sapper both usefile system naming conventions to organize the site's final page heirarchy. The file structure within the/pages directory roughly translates to how the site will be built.

/pages/index.svelte/ becomes the homepage and/pages/page-name.svelte/ would become/page-name. Routify can also translate index files from directories into routes so/pages/nested/index.svelte/ would live at/nested on the live site. Files, or folders, can be named with square brackets surrounding them to pass dynamic parameters —/pages/nested/[name].svelte/ would catch any direct subpage of the/nested url, and would pass thename value into that page as a parameter.

My site structure was pretty shallow and my/pages directory looked like this:

/pages ├─ index.svelte ├─ [owner] │ ├─ index.svelte │ └─ [palette].svelte
Enter fullscreen modeExit fullscreen mode

<Router> Component

Routify interacts with the browser'swindow.url API by wrapping an entire app in the<Router> component. Thedev,serve, andbuild commands in thepackage.json file will need to be changed to use theroutify command instead ofrollup.

// app.svelte<script>  import { Router } from '@sveltech/routify'  import { routes } from '@sveltech/routify/tmp/routes'</script><Router routes={routes} />
Enter fullscreen modeExit fullscreen mode

This looks sort of magical as theroutes object doesn't need to be manually defined anywhere. Instead it's imported from a file generated by Routify based on the file structure I outline above. This handles pushing the correct url to the browser when a user clicks a link, as well as catching a nested URL that a user might visit directly and loading the correct internal page.

Passing Url Parameters

Routify will link between pages with regular<a href='...'> tags, but to actually pass dynamic data into the special[name].svelte file you need to use the$url helper. The$url() function accepts a URL string, using colons to denote variables, and also a'params' object.

For my site, eachowner contains aname andslug property.

{  name: 'Ryan Filler',  slug: 'ryan-filler'}
Enter fullscreen modeExit fullscreen mode

In order to create a link that would read correctly and take the user to the correct page, the anchor tag looks like this.

<a href={$url('/:owner', {owner: owner.slug})}>  {owner.name}</a>
Enter fullscreen modeExit fullscreen mode

sanity.io

Now that the site could dynamically access data, that data needed somewhere to live.

Sanity describes itself as a "fast, flexible platform for delivering structured content," which sort of means that it functions as a database as a service. Sanity works as a hosted store for data that can be easily retrieved via theirHTTP API and theirGROQ language. GROQ, which is short for _Graph Oriented Query Language_, is very similar in syntax toGraphQL but without the need to write your own database resolvers. Sanity lets you post to a url, or use one of theirlanguage client interfaces to fetch and post data.

I started my project with theofficial Sapper Starter, but I wouldn't actually recommend doing this. This starter comes with a Sapper site (that I ended up replacing with Routify), and a way to locally run the backendSanity Studio. If you're using Sanity more as a CMS this probably makes sense, but since I really just needed a data store I found it much easier to skip the local development and work on my database directly in the hosted version onsanity.studio.

The "structured" aspect of Sanity's approach to data comes from theschema.js file. A schema is a description of each datatype and whatfields, or accessible values, will be collected on eachtype. Eachfield is given a"schema type" which defines what someone querying the data can expect to get back.

Theschema.js file isn'ttechnically required since the Sanity API will accept any data and store it, but filling out this file helps make sure any posted data conforms to expectations.

Since each page would need to be backed by a corresponding data type the schema for my site was roughly the same as the site map. The homepage would be a collection ofowners, each of which is defined in the schema like this:

{  title: 'Owner',  name: 'owner',  type: 'document',  fields: [    {      title: 'Name',      name: 'name',      type: 'string',    },    {      title: 'Slug',      name: 'slug',      type: 'string'    }  ]}
Enter fullscreen modeExit fullscreen mode

What makes Sanity and other services like it so awesome is the ability to define relationships between data without having to deeply understand the complexities ofdata model relationships. I used to work at an agency that used a custom PHP CMS, and having to decide betweenbelongs-to,belongs-to-many,has-one, orhas-many was hands down the hardest part of working on any site.

Since an Owner would be creating Palettes, I used thereference type in my Palette schema to create this association relationship.

{  title: 'Color Palette',  name: 'palette',  type: 'document',  fields: [    {      title: 'Title',      name: 'title',      type: 'string'    },    {      title: 'Slug',      name: 'slug',      type: 'string'    },    {      title: 'Owner',      name: 'owner',      type: 'reference',      to: [{type: 'owner'}]    },    {      title: 'Colors',      name: 'colors',      type: 'array',      of: [{type: 'color'}]    },     ]}
Enter fullscreen modeExit fullscreen mode

Each Palette would hold a list of Colors so I used theof key to specify which other data type each element of the array would need to conform to.

{  title: 'Color',  name: 'color',  type: 'object',  fields: [    {name: 'name', type: 'string', title: 'Name'},    {name: 'value', type: 'string', title: 'Value'}  ]}
Enter fullscreen modeExit fullscreen mode

Getting Data

In order to make the next steps easier, I went ahead and seeded some data via the Studio hosted on sanity.io.

{  "name": "Ryan",  "slug": "ryan",  "_id": "b524755a-25b6-4619-b92d-fb23779ea709",  "palettes": [    {      "title": "CMYK",      "slug": "cmyk",      "_id": "a8659824-ebcd-4afe-a099-fffee9018024",      "owner":{        "_ref": "b524755a-25b6-4619-b92d-fb23779ea709",      },      "colors":[        {          "name": "cyan",          "value": "#00FFFF"        },        {          "name": "magenta",          "value": "#FF00FF"        },        {          "name": "yellow",          "value": "#FFFF00"        },        {          "name": "black",          "value": "#000000"        }      ]    }  ]}
Enter fullscreen modeExit fullscreen mode

Assuming we have data in the Sanity backend, the next step is to get it to the frontend. The easiest way to do this is to use theJavaScript@sanity/client package. This package provides an interface that can be called in other JavaScript files that, among other things, makes it much easier to work with GROQ queries. It needs a little bit of configuration to hook it up to your specific Sanity data.

// sanityClient.jsimport sanityClient from '@sanity/client'const client = sanityClient({  projectId: process.env.SANITY_ID,  dataset: 'production'  useCdn: false})export default client
Enter fullscreen modeExit fullscreen mode

Sanity says that theprojectIDisn't sensitive data and is fine to expose over network calls, but I went ahead and put it in anENV variable using thedotenv package just to be safe (more on this in theSending Data section). Also, because of the live editing aspect of this project, theuseCdn: false option is actually super important in this scenario to make sure that users aren't seeing cached data after making updates to their colors.

GettingAll Data

Once the Sanity client is configured it can be imported and used in any component. To retrieve data, theclient.fetch() method must be passed a GROQ query as the first argument. It can also accept an optional second arguement, aparams object containing dynamic variables.

// index.svelte<script>  import client from '../sanityClient.js'  const query = '*[_type == "owner"]'  const getData = async () => {    return client.fetch(query)    .then(response => {      ...    })    .catch(err => this.error(500, err))  }</script>
Enter fullscreen modeExit fullscreen mode

This paragraph fromthe GROQ documentation explains how a simple query works.

* returns all documents in the dataset that the current user has permissions to read. The documents are passed to a filter, which retains documents for which the expression evaluates totrue.

Since this query fetches the data for the homepage, which will show a list of all Owners, the query will use the* selector to return all documents, then filter out ones that do not pass the[_type == "owner"] check and return only Owners.

Because theclient.fetch() returns aPromise, Svelte'sawait blocks make it super easy to handle the different loading states in the UI.

// index.svelte<script>  const getData = async () => {...}</script>{#await getData()}  <Loading />{:then}  ...{:catch error}  <Error />{/await}
Enter fullscreen modeExit fullscreen mode
GettingSome Data

Once someone is on a specific Owner's page it makes sense to only load the data relevant to that Owner. To do this, we can use theclient.fetch()params argument to pass variables to the GROQ query. Because part of the Routify link data includes theowner.slug data, that can be used to look up a Sanity owner with matchingslug value.

// [owner]/index.svelte<script>  import { params } from '@sveltech/routify'  import client from '../../sanityClient'  const { owner } = $params  const query = `    *[_type == 'owner' && slug == $owner] {      ...    }  `  const queryArgs = {     owner: owner  }  const getData = async () => {        return client.fetch(query, queryArgs)        .then(response => {       ...    })    .catch(err => this.error(500, err))  }</script>
Enter fullscreen modeExit fullscreen mode

$params (not to be confused with the Sanityparams argument) is a Svelte store imported from the@sveltech/routify package. It allows information to be pulled from the<Router /> and used inside other components.

Inside the GROQ query, the$ identifier can be used to interpolate a dynamic value. I did some abstracting in the block above to make the component a little easier to read, but the endclient.fetch() call will look like this:

client.fetch(`  *[_type == 'owner' && slug == $owner] {    // projection here  }`, {owner: $params.owner}).then(response => {    ...})
Enter fullscreen modeExit fullscreen mode

Once we've looked up theowner that matches the current page, we can use Sanity'sprojections on that filter and get more data from it. The query can be expanded to look up a specificowner, get their_id attribute, then use that_id to look up all documents of thepalette type that have a_ref value matching the Owner's_id.

const query = `  *[_type == 'owner' && slug == $owner] {    name,    slug,    _id,    'palettes': *[_type == 'palette' && owner._ref == ^._id] {      title,      slug    }  }`
Enter fullscreen modeExit fullscreen mode

Projections are similar toGraphQLfields and can be used to shape the data that will come back in the HTTP response. The query above would return JSON data in the shape built out in the Projection. In a Projection, you are able to ask for anyfield that exists on atype.

{  "result": [    {      "name": "Ryan",      "slug": "ryan",      "_id": "b524755a-25b6-4619-b92d-fb23779ea709",      "palettes": [        {          "slug": "website-2020",          "title": "website 2020"        },        { ... },        { ... },        { ... },        { ... }      ]    }  ]}
Enter fullscreen modeExit fullscreen mode

Filters and projections can be nested as deep as necessary (though keep in mind this might slow a query down), and as manyparams as needed can be passed. That means that on the page for a specific color palette, the query looks like this:

const { owner, palette } = $paramsconst query = `  *[_type == 'owner' && slug == $owner] {    name,    slug,    _id,    'palettes': *[_type == 'palette' && owner._ref == ^._id && slug == $palette] {      title,      slug,      _id,      colors    }  }`const queryArgs = { owner, palette }const getData = async () => {  return client.fetch(query, queryArgs)  .then(response => {    ...  })  .catch(err => this.error(500, err))}
Enter fullscreen modeExit fullscreen mode

... and would return data like this:

{  "result": [    {      "name": "Ryan",      "slug": "ryan",      "_id": "b524755a-25b6-4619-b92d-fb23779ea709",      "palettes": [        {          "title": "website 2020",          "slug": "website-2020",          "_id": "NOuiz2BuYnd9zvcp3ofESs",          "colors": [            {              "name": "primary",              "value": "#0f3287"            },            {              "name": "dark",              "value": "#151d43"            },            {              "name": "light",              "value": "#eee9d5"            },            {              "name": "highlight",              "value": "#da4181"            },            {              "name": "active",              "value": "#df5909"            },            {              "name": "disabled",              "value": "#6577a6"            }          ]        }      ]    }  ] }
Enter fullscreen modeExit fullscreen mode

The GROQ language can be a little confusing at first, and making a malformed query doesn't always return a super helpful error message. One helpful tool that Sanity provides is theVision Plugin. This plugin can be added to the project's Studio, either locally or hosted onsanity.studio, and used to try out queries and see their potential response data.

sanity vision plugin showing query and response for an owner's color palettes

Moving data around with Stores

Cool, so, once we have data, we need to move it around to the correct places. To do this I relied heavily on Svelte'sstores.

On each page, there are two main data concerns — the Sanity data that needs to be shown, and the actions that a user can take to interact with that data. In astores.js file, I set up twowritables that could hold this data.

// stores.jsimport { writable } from 'svelte/store'export const data = writable({  loading: false,  error: null,  response: [],  owners: [],  owner: null,  palettes: [],  palette: null,  colors: []})export const actions = writable({  buttons: [],  current: null,  error: false})
Enter fullscreen modeExit fullscreen mode

To keep page data in sync, every time I made a call to the Sanityclient I then took the response from thefetch call and set the page's$data store accordingly.

// index.svelte<script>  import client from '../sanityClient.js'  import { data } from '../stores.js'  const query = '*[_type == "owner"]'  const getData = async () => {    return client.fetch(query)    .then(response => data.set({      ...data,      owners: response    })    ).catch(err => this.error(500, err))  }</script>{#await getData()}  <Loading />{:then}  {#if $data.owners.length}    {#each $data.owners as owner}      <a href={$url('/:owner', {owner: owner.slug})}>        {owner.name}      </a>    {/each}  {:else}    uh oh, there are no users.  {/if}{:catch error}  <Error />{/await}
Enter fullscreen modeExit fullscreen mode

$data holds multiple types of page information, so I used the...spread to copy the existing data andset a new value that only overwrites new data for the current page. On pages that had complicated data, it makes sense to usedestructuring assignment to grab data off of theresponse object.

// [owner]/palette.svelteconst getData = async () => {  return client.fetch(query, queryArgs)  .then(response => {    const {      name,      slug,      _id: ownerId,      palettes    } = response[0]    const {      title,      slug,      _id: paletteId,      colors    } = response[0]    return data.set({      ...data,      owner: {         name: name,        slug: slug,        id: ownerId      },      palette: {        title: title,        slug: slug,        id: paletteId      },      colors: colors.map(color => ({        name: color.name,        value: color.value      }))    })  }  ).catch(err => this.error(500, err))}
Enter fullscreen modeExit fullscreen mode

To load the specific actions for each page, I called thewriteable.set() method any time a new layout was visited. On the/owner/palette route, a user needs to be able to add a new color, edit existing colors, or edit the JSON code.

// [owner]/palette.svelte<script>  import { actions } from '../../stores.js'  actions.set({    buttons: [      {        text: 'add a color',        title: 'addColor',         icon: 'add',        action: () => $actions.current = 'addColor'      },      {        text: 'edit colors',        title: 'editColors',         icon: 'edit',        action: () => $actions.current = 'editColors'      },      {        text: 'see JSON',        title: 'seeCode',         icon: 'code',        action: () => $actions.current = 'seeCode'      }    ]  })</script>
Enter fullscreen modeExit fullscreen mode

I mapped through these objects and used each to construct a button. Each configuration object could set things like a title and icon, and set the$actions.current value to render a different component to allow the user to edit data.

// actions-buttons.svelte<script>  import { actions } from '../stores.js'  const icons = { ... } // a list of SVGs</script>{#each $actions.buttons as button}  <button    title={button.disabled ? `uh oh, there's a data error` : title}    on:click={button.action}    class:active={button.active}    disabled={button.disabled}  >    <svelte:component this={icons[button.icon]} />  </button>{/each}
Enter fullscreen modeExit fullscreen mode

The actions component was a<form> tag that contained a giantif...else switch to conditionally show inputs that would post data back to the Sanity client.

// actions-area.svelte<script>  import { actions } from '../stores.js'  const createNewColor = () => { ... }</script><form id='action'>  ...  {if $actions.current === 'addColor'}    <input      type='text'      id='new-color-name'      name='new-color-name'      bind:value={$data.color.name}    />    <input      type='color'      id='new-color-value'      name='new-color-value'      bind:value={$data.color.value}    />    <button on:click={createNewColor}>save color</button>  {:else if $actions.current === ...}</form>
Enter fullscreen modeExit fullscreen mode

Once the correct<input> elements are rendered, the Sveltebind: directive is used to update the$data store every time a user makes a change.

Netlify Functions

Now that we have data and users can create or edit data, we need a way to send that new data back to Sanity. TheSanityclient can help here, with the.create and.mutate methods, but sending data requires a little more care than getting data.

Technically, anyone is able to hit the Sanity url endpoint and query data. There are ways to lock this down, but in my app I'm not too worried about it. However, I don't want just anybody to be able towrite to that endpoint, which means I need asanityClient configured with anauthentication token. Tokens can be generated from the Sanity dashboard onmanage.sanity.io, under Settings > API > CORS Origins.

const client = sanityClient({  projectId: process.env.SANITY_ID,  dataset: 'production',  token: process.env.SANITY_TOKEN})
Enter fullscreen modeExit fullscreen mode

I'm choosing to host this site statically on Netlify, so I can't include that token in the project. Firstly, it would be exposed in the project's git repository, and secondly it would be sent openly "over the wire" in each HTTPPOST request.

This is whereNetlify's serverless functions come in.

"Serverless functions" are not actually functions that don't run on a server, they just don't run onyour server. A more accurate name for them is "cloud functions" since they run on other servers that exist only to perform one small action at a time.

Netlify Functions are pretty straightforward to set up. There are steps in the documentation on how to set them up via theonline interface, or how to set them up directly in thenetlify.toml file.

I set up two small functions, one tocreate and one tomutate, and they look almost exactly the same. The contents of each file in the/api folder will be packaged and deployed to a cloud server somewhere, so a newsanityClient needed to be created instantiated of each.

// api/create.jsrequire('dotenv').config()const sanityClient = require('@sanity/client')const client = sanityClient({  projectId: process.env.SANITY_ID,  dataset: 'production',  token: process.env.SANITY_TOKEN})exports.handler = async function(event, _context, callback) {  const payload = JSON.parse(event.body)  const result = await client.create(payload, { returnDocuments: true })  callback(null, {    statusCode: 200,    body: JSON.stringify(result)  })}// api/mutate.jsrequire('dotenv').config()const sanityClient = require('@sanity/client')const client = sanityClient({  projectId: process.env.SANITY_ID,  dataset: 'production',  token: process.env.SANITY_TOKEN})exports.handler = async function(event, _context, callback) {  const payload = JSON.parse(event.body)  const result = await client.mutate(payload, { returnDocuments: true })  callback(null, {    statusCode: 200,    body: JSON.stringify(result)  })}
Enter fullscreen modeExit fullscreen mode

ThereturnDocuments: true option in the configuration object is important because it tells Sanity to return a representation of the document after the update request has succeeded. This means that the app can listen for thefetch.response and update accordingly with the latest data.

To avoid exposing my token credentials in these functions, I used thedotenv package again. After this package is installed, itsconfig() method needs to be called as soon as possible in the project, so I put it at the top of myrollup.config.js file.

// rollup.config.jsimport dotenv from 'dotenv'dotenv.config()
Enter fullscreen modeExit fullscreen mode

process.env values can be defined in a.env file locally (make sure to add this to your.gitignore and commit it!), and in the Settings > Deploys > Environment section of the Netlify dashboard. These values don't have to match in both places, but they do both need to be valid tokens.

Netlify dashboard, Settings Deploys > Environment

Sending Data

Once the Netlify Functions have been deployed, they live at aurl corresponding to the name of the.js file.

To send data to these endpoints, I intercepted thePOST action of the<form> element in myactions-area.svelte component. Since the<input> values are bound to the$data store, I could get values directly from the$data object andfetch against my Netlify function url.

Adding new data means posting a new object to the/functions/create endpoint. This object defines the_type and information of the new document to be created.

// actions-area.svelteconst createNewOwner = () => {  fetch(`/.netlify/functions/create`, {    method: 'POST',     credentials: 'same-origin',    headers: { 'Content-Type': 'application/json' },    body: {      _type: '$data.owner',      name: $data.owner,      slug: slugify($data.owner)    }  }).then(response => response.json())  .then(response => {    $data.owners = [...$data.owners, response]  })  .catch(error => console.log('error', error))}
Enter fullscreen modeExit fullscreen mode

In order to make sure that a user is seeing the most up to date representation of the data, every time afetch call is made the$data store is updated with theresponse object from the Netlify function.

Adding to thecolors array, since it involves editing existing data rather than creating new data, is slightly different. To do this I need to hit my/functions/mutate endpoint and give Sanity apatch to make.

// actions-area.svelteconst createNewColor = () => {  fetch(`/.netlify/functions/mutate`, {    method: 'POST',     credentials: 'same-origin',    headers: { 'Content-Type': 'application/json' },    body: [      {        patch: {          id: $data.palette.id,          insert: {            after: 'colors[-1]',            items: [{ name: $data.color.name, value: $data.color.value }]          }        }      }    ]  }).then(response => response.json())  .then(response => {    $data.colors = response[0].colors)  })  .catch(error => console.log('error', error))}
Enter fullscreen modeExit fullscreen mode

Because I need to add to append to the current array of colors the/functions/mutate endpoint takesan array of mutations to be performed on the document with the correspondingid value. Because of thereturnDocuments: true option sent along with the request, I can then take the entire new array that contains both my old and new colors and set it to the$data store.

Still TODO

Tests

This application could probably use some tests, and this would be a good opportunity to exploremocking network activity using Cypress. I might get around to this in the future, but since this was just a fun side project there's also a chance I won't make time to thoroughly test it.

Authentication

Right now, this app works on the honor system — anyone can edit any colors. I hope they won't, but if this becomes a problem I could add authentication that would limit people to only making edits to things they were an Owner of. This might be a good learning experience for the fun of it, too. I could maybe implementNetlify Auth, which I've worked a little with in the past, or take a look at something completely new likeAuth0. I've heard good things about Auth0 and how it can be used as a serverless function.

Top comments(0)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

Designer turned developer. I care about things like #javascript, #css, and #accessibility.
  • Location
    Memphis, TN
  • Work
    Frontend Developer at Lensrentals
  • Joined

More fromryanfiller

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp