Version history:
- 2024-07-10: Mark todos as just “Unfinished” instead, and try to apply some of the comments that I wrote. I don't plan to update this further.
- 2022-11-30: rewrite to make it not about Kemdict
- 2022-11-19: clarify that this is about SvelteKit on serverless platforms
- 2022-11-17: first draft
Still a rough draft even though the whole thing has been rewritten.
Reference repository: https://github.com/kisaragi-hiu/demo-sveltekit-sqlite
We're not reading and writing runtime data. A dedicated database (like Supabase) is better in that case. Here, I just want to read from an already created SQLite database.
Keep in mind that if the amount of data you have warrants putting it in an SQLite database and not just JSON, it is almost always better to use a dedicated database or to deploy to Node instead, because a serverless function would have to read the database every time it's invoked.
Draft note: But we're still doing it. Why? (1) This is what I was looking for a week or two ago, (2) moving between the two is easy thanks to the adapters architecture
“Serverless” here means “the server side code is written as short-lived one-request functions”, which in practice means “I'm using Netlify (or Vercel or Cloudflare etc.) to make the site with no upfront monetary investment and no need to manage my own server”, which is its nornal definition despite contradicting what “server-less” may seem to suggest.
Create the project
While you can use SvelteKit's template (npm create svelte@latest my-app
), deferring to the template defeats the point of writing a blog post about it.
mkdir demo-sveltekit-sqlite
cd demo-sveltekit-sqlite
git init
echo "{}" > package.json
- Using Git, of course.
- In my opinion, because a project like this isn't supposed to be published as a package (i.e. required as a dependency by anything), it is only cumbersome to have to fill out the name and version, so what I often end up doing is to just create a
package.json
with an empty object in it.
Also, set up which files should be ignored by git. .gitignore
:
node_modules/
.svelte-kit/
"type": "module"
There are two ways to import modules in NodeJS: ECMAScript modules (import
) and CommonJS modules (require
), with the former being preferred in new code because it is part of standard JavaScript (ECMAScript).
"type": | extension | which system is used? |
---|---|---|
omitted | ".js" | CommonJS |
"module" | ".js" | ES modules |
"commonjs" | ".js" | CommonJS |
anything | ".cjs" | CommonJS |
anything | ".mjs" | ES modules |
Because we want this project to use ES modules unless otherwise specified, we'll write this in package.json
:
{
"type": "module"
}
Install SvelteKit
npm install -D @sveltejs/kit vite
I'm going to use mdsvex immediately. This is a Svelte preprocessor that lets you write Svelte components (and thus SvelteKit routes) in Markdown (MDX).
npm install -D mdsvex
I'm also going to use svelte-preprocess. This is another Svelte preprocessor, and it lets you write component logic in TypeScript or style in Sass, among other features.
npm install -D svelte-preprocess
Configure SvelteKit and Vite
Vite is like a supercharged dev server and a production bundler that figures out a bunch of stuff for you. In reality the production bundler part uses Rollup, but that is almost an implementation detail that you don't have to worry about unless you want to use Rollup plugins.
For example, using Tailwind CSS is just a matter of installing the tailwindcss
package, setting up postcss.config.js, and importing your source CSS as usual. Vite knows what to do, and it will run CSS files through the PostCSS pipeline (which Tailwind runs in) when importing once it sees the PostCSS config.
In the case of SvelteKit, Vite doesn't have support for it built in, so SvelteKit provides a Vite plugin to glue them together.
vite.config.js
:
import { sveltekit } from "@sveltejs/kit/vite";
export default {
plugins: [sveltekit()],
};
- This configuration grabs SvelteKit's Vite plugin and tells Vite to use it.
- See Vite's documentation for more configuration options. For example, I set
clearScreen
to false because it doesn't work well inside of Eshell or emacs-taskrunner.
svelte.config.js
:
import { mdsvex } from "mdsvex";
import adapter from "@sveltejs/adapter-auto";
import preprocess from "svelte-preprocess";
export default {
extensions: [".svelte", ".svelte.md", ".md", ".svx"],
preprocess: [
preprocess(),
mdsvex({
smartypants: {
dashes: "oldschool",
},
extensions: [".svelte.md", ".md", ".svx"],
layout: "src/lib/MarkdownLayout.svelte",
}),
],
kit: {
adapter: adapter(),
},
};
- In
extensions
, we tell Svelte which files should be considered as components. By default this is.svelte
, but we add some more to then be recognized by mdsvex. - We need an adapter, which turns an intermediate build result into code that can be deployed on different platforms, including, for example, Cloudflare, Netlify, and as a server side Node application. The default here is adapter-auto, which choose one to use depending on where the application is being built.
- In
preprocess
, we declare the Svelte preprocessors in order. We run it throughsvelte-preprocess
first, thenmdsvex
— I'm not sure this is the best order though. We tell mdsvex that
- We want smart dashes in the style where two dashes become an en-dash and three become an em-dash
- Markdown files should act as if they are wrapped by the component exported by
src/lib/MarkdownLayout.svelte
(we'll create it later)
Not using adapter-auto
adapter-auto
looks at the build time environment to determine which adapter it should use. But this only works if you're building on the same platform as you are deploying. For building, I prefer to use GitHub Actions instead of using, say, Cloudflare Pages or Netlify or Vercel's build system (CI).
So I prefer to just use adapter-netlify (for example) directly.
npm install -D @sveltejs/adapter-netlify
import { mdsvex } from "mdsvex";
// I prefer to name it like this
import adapterNetlify from "@sveltejs/adapter-netlify";
import preprocess from "svelte-preprocess";
export default {
extensions: [".svelte", ".svelte.md", ".md", ".svx"],
preprocess: [
preprocess(),
mdsvex({
smartypants: {
dashes: "oldschool",
},
extensions: [".svelte.md", ".md", ".svx"],
layout: "src/lib/MarkdownLayout.svelte",
}),
],
kit: {
adapter: adapterNetlify(),
},
};
Set up Prettier
Prettier formats code for you so you never have to worry about it again.
As it doesn't support Svelte syntax out of the box, we have to install it with prettier-plugin-svelte
.
npm install -D prettier prettier-plugin-svelte
Then register it in .prettierrc
(I prefer YAML):
overrides:
- files: "*.svelte"
options:
parser: "svelte"
Adding some pages and some components
Let's finally create some pages. Create the directory structure:
mkdir src/
mkdir src/lib
mkdir src/routes
Create src/app.html
. This is SvelteKit's root template.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="referrer" content="no-referrer" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
%sveltekit.head%
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
</head>
<body>
<div data-sveltekit-reload class="container">%sveltekit.body%</div>
</body>
</html>
- See the documentation for an explanation for the
%sveltekit.*%
stuff. - I'm adding
data-sveltekit-reload
to a containing element so that it will apply to every a tag in the app. This disables client side routing across the entire app, and is equivalent torouter = false
in previous versions of SvelteKit. A normal app should not have this — I added this here to demonstrate how you would achieve this because it took me way too long to figure out. Normally you'd keep the router on, and also use the preload tags to get navigation to appear snappier.
Create src/routes/+layout.svelte
. Layouts are components that are wrapped around every page that it applies to. src/routes/+layout.svelte
applies to every page, while src/routes/abc/+layout.svelte
would only apply to pages under abc/
.
The difference between this file (the root layout) and app.html
is that this is still a Svelte component, so you can import files in a script tag, use components, etc. Doing so lets you tell Vite that all your pages depend on the files being imported.
src/routes/+layout.svelte
:
<script>
// import "../src.css";
// We'll add the CSS later
</script>
<slot />
src/lib/MarkdownLayout.svelte
(the component we declared above to wrap all Markdown files):
Note that this isn't a SvelteKit “layout” like +layout.svelte
. This is just a normal component that's not involved in routing, it's just that we've told mdsvex to use this component to wrap Markdown content.
<div class="prose">
<slot />
</div>
At this point we can already start the dev server.
npx vite dev
Note that I'm not using the npm scripts that Vite ships in its templates. Is there really a point to saying npm run dev
instead of npx vite dev
? In terms of using consistent task names across projects, sure, but not when explaining how things work. Just keep in mind that the former runs a script that's defined in the project, while the latter is actually calling the command itself.
Now let's add the root page.
src/routes/+page.svelte
:
<script>
const description = "Here's my description!";
</script>
<svelte:head>
<meta name="description" content={description} />
<title>Here's a title!</title>
</svelte:head>
<h1>Example reading data from a local SQLite database</h1>
<p>Hello World!</p>
Adding Tailwind CSS
I find that, when using a component framework / templating system, Tailwind CSS really allows you to be more confident that changing parts of your CSS isn't going to break some random thing elsewhere.
Install it:
npm install -D tailwindcss
Create a postcss.config.cjs
(because Tailwind's config and PostCSS plugins use CommonJS):
module.exports = {
plugins: [
// You don't need to install this separately: you already depend
// on postcss-import when installing tailwindcss.
require("postcss-import"),
require("tailwindcss/nesting"),
// This must come last
require("tailwindcss"),
],
};
Vite will detect the presence of the PostCSS config and process the CSS with PostCSS automatically. Because Tailwind is actually a PostCSS plugin (that happens to have its own CLI), this means it will be processed by Tailwind.
Then create a tailwind.config.cjs
:
module.exports = {
content: ["./src/**/*.{svelte,md,ts,js,html}"],
};
- This file needs to exist, otherwise Tailwind will emit an error.
content
specifies where Tailwind utility classes might be referenced. Utility classes that are referenced will be included in the final output, and those that aren't won't. If you don't specifycontent
, no utility tags will be emitted, so it's de-facto necessary.- With Vite, you don't have to worry about where that output goes: during development it is inlined, and in a production build it is placed in the build output, with the relevant
<link>
tags added automatically. - See Tailwind's docs on configuration for what other options are available here.
Now create the CSS file:
src/src.css
:
@tailwind base;
@tailwind components;
@tailwind utilities;
And actually import the CSS file in the root layout, src/routes/+layout.svelte
:
<script>
import "../src.css";
</script>
<slot />
Now use them in the root route, src/routes/+page.svelte
:
<script>
const description = "Here's my description!";
</script>
<svelte:head>
<meta name="description" content={description} />
<title>Here's a title!</title>
</svelte:head>
<main class="w-[95%] max-w-2xl mx-auto mt-48">
<h1 class="text-3xl font-bold">Example reading data from a local SQLite database</h1>
<p>Hello World!</p>
</main>
Preparing the database
We're ready to start creating pages based on data from a database. For this I'll use a sample database from codecrafters.io. Download the superheros.db
and just place it in project root for now.
Read the database on a server route
Unless you want to dabble with sending both a WebAssembly build of SQLite and the full database to your user, we'll have to read the database on the server side. (I don't know how to do that anyways.)
In case this is confusing: the server side of a “serverless” application is the serverless functions.
This server side can be a +page.server.js
(pages only rendered on the server side) or a +server.js
(API routes that return raw data and not HTML). In this example I'm going to use an API route, then fetch from it on the client side.
We need better-sqlite3
for this.
npm install better-sqlite3
Note that currently better-sqlite3
does not install for Node 19. On Arch Linux, you can move to an LTS by installing, say, the nodejs-lts-hydrogen
package, which is Node 18.
Also note that this means we have a hard dependency on NodeJS itself, so we cannot deploy to Cloudflare Pages because Cloudflare Workers has its own runtime that isn't compatible.
I'll put the API route in /heroes
, with the API /heroes?q=<query>
; it will return a list of heroes from the database whose names match the query.
In src/routes/heroes/+server.js
:
import { json, error } from "@sveltejs/kit";
import Database from "better-sqlite3";
import fs from "node:fs";
let db = new Database(fs.readFileSync("superheroes.db"));
export function GET({ url }) {
const query = url.searchParams.get("q");
if (!query) {
throw error(401, "Query (`?q=`) is required");
}
const stmt = db.prepare("select * from superheroes where name like ?");
return json(stmt.all(`%${query}%`));
}
- See
server.js
docs for details on GET,json
, etc. - See better-sqlite3's docs for how we're interacting with the database, particularly
Database#prepare()
,Statement#all()
, and Binding Parameters. - We're creating the database by passing it the file contents. This creates an in-memory database, changes to which will be lost. That's fine because the whole premise is that we're reading from a preexisting database that we don't write to.
You could make the database writable by passing the file path to the constructor directly, but keep in mind that this will not work in a serverless function, because each invocation is getting its own copy and the modified versions will not be merged together. You need to use a database service or abandon the premise of running your app on serverless platforms.
- The file path is relative to the project root, i.e. where you started Vite.
- On Netlify, in a monorepo (i.e. your project root is not your repository root), the file path will be relative to the project root during build as expected, but during runtime of the serverless function it will be relative to repository root instead. This feels like a bug, but we'll just have to work around it later.
Now test the API route out: navigate to localhost:5173/heroes?q=aqua
, and you should see a JSON array of heroes with “aqua” in their names.
Create a component for rendering a list of heroes
Create src/lib/components
. The name “components” is not special, but “lib” is: things under this folder can be imported as $lib/…
instead of having to use relative paths.
Then create src/lib/components/SuperHeroes.svelte
:
<script>
export let items = [];
</script>
<ul>
{#each items as item}
<li class="w-full flex justify-between">
{item.name}{#if item.first_appearance_year}<span
>First appearance: {item.first_appearance_year}</span
>{/if}
</li>
{/each}
</ul>
export let <name>
is Svelte's way of declaring input arguments for components. We set it to an empty array by default, because theeach
below requires it to be an array.- Each item gets a list item; Tailwind classes are used liberally.
Creating a search bar, fetching results, and rendering results
<script>
import SuperHeroes from "$lib/components/SuperHeroes.svelte"; // 1
const description = "Here's my description!";
async function update(value) {
if (value.trim().length > 0) {
items = await fetch(`/heroes?q=${value.trim()}`).then((v) => v.json());
} else {
items = [];
}
} // 6
let items = [];
let value;
$: update(value); // 5
</script>
<svelte:head>
<meta name="description" content={description} />
<title>Here's a title!</title>
</svelte:head>
<main class="w-[95%] max-w-2xl mx-auto mt-48">
<h1 class="text-3xl font-bold">
Example reading data from a local SQLite database
</h1>
<p>Search for superheroes below</p>
<input
class="p-2"
type="search"
autocomplete="off"
placeholder="Search"
bind:value
/> <!-- 2, 4 -->
<SuperHeroes {items} /> <!-- 3 -->
</main>
- We have to import the component.
- The value of the input element is always written into the
value
variable of the component. This is done withbind:
. Because the variable and the element field have the same name, we can writebind:value
instead ofbind:value={value}
. In fact, the former seems to be the preferred style according to the formatter (Prettier andprettier-plugin-svelte
). - Similarly,
<SuperHeroes {items} />
is the preferred shorthand for<SuperHeroes items={items} />
. - Also note that there doesn't seem to be a way to add comments in the element properties part.
- Whenver
value
is updated, we runupdate(value)
. This behavior is enabled by the$:
. Svelte analyzes the right hand side to find variables that are depended on, and if they change the statement will be re-run. - The
update
function fetches the result from the API route and setsitems
directly, triggering a rerender ofSuperHeroes
if it changed. As a special case, if the input is empty, we clear theitems
array.
This does a lot of network requests, but I think it's fine as a demonstration.
(Unfinished) Compressing the database
- Just use gz
new Database(zlib.gunzipSync(fs.readFileSync("data.db.gz")))
(Unfinished) automating stuff with Make or npm scripts
(Unfinished) GitHub Actions
- Install deps
- Build (with correct Node version)
- Install netlify-cli
- Deploy
Deploying to Netlify
Unfinished: actually test this on our demo repository
To deploy to Netlify, we need to:
- Use adapter-netlify (or use adapter-auto and satisfy its requirements for building for Netlify)
Make sure we install native dependencies — just
better-sqlite3
here — in the same Node version as would be used to run the serverless function. This is because Netlify copies the build-timenode_modules
to the context of where the serverless functions would be run.- The build time Node version is controlled by the
NODE_VERSION
on Netlify's build servers - The run time Node version is controlled by
AWS_LAMBDA_JS_RUNTIME
; the latest version as of writing is Node 16, specified as “nodejs16.x”
- The build time Node version is controlled by the
- Make sure the database is actually copied to the right place
netlify.toml
:
[functions]
included_files = ["superheroes.db"]
On Netlify, in a monorepo (i.e. your project root is not your repository root), the file path will be relative to the project root during build as expected, but during runtime of the serverless function it will be relative to repository root instead. This feels like a bug, but we can work around it.
Instead of
let db = new Database(fs.readFileSync("superheroes.db"));
assuming the project lives at ./subdir
under the repository, you could do
function readDB(path) {
return new Database(fs.readFileSync(path));
}
let db;
try {
db = readDB("superheroes.db");
} catch (e) {
if (e instanceof Error && e.code === "ENOENT") {
db = readDB("subdir/superheroes.db")
} else {
throw e
}
}