We’ve talked before about some of the great features Deno brings to the table: first-class TypeScript support, a solid standard library, support for Web standards, and implicit security. All of this makes Deno great for writing scripts and servers, but it also works well for writing client-side applications. You can create a complete web app with nothing but Deno (and libraries 😄): client, server, and even tests!
In this post, I’m going to walk through a simple Deno-based Todos app. This app will be pretty basic — a REST server, SQLite database, and a React client. It uses Deno’s native bundler to dynamically create a client bundle. Some sample tests are written using Deno’s native test runner. The app doesn’t have an explicit build step — you can just start the server and it will be ready to go. Everything is implemented with, or managed by, Deno, with no requirement for external tooling such as webpack, babel, Jest, nodemon, or the TypeScript compiler.
This post assumes that the reader is reasonably familiar with client and server development using Node, so I’ll just be presenting highlights rather than going through the whole app in detail. The full app source can be found here.
Tooling
Deno provides all the necessary tooling (other than a code editor itself):
deno run
for type checking and executingdeno lint
for ensuring the code is cleandeno test
to run testsdeno lsp
for IDE integration (this will likely be handled transparently by your IDE, but it’s using Deno behind the scenes)deno fmt
to format the code (this will also likely be handled transparently by your IDE)deno task
to run development scriptsdeno compile
to build a completely standalone version of the app
Organization
The app is broken into two major parts: a client and a server. They are mostly distinct, although they do share a Todo type representing a single todo item. Deno doesn’t encourage any particular project structure, so file organization is very flexible. The same app uses the following structure:
deno-todos/
deno.json
import_map.json
mod.ts <-- command line interface
client/ <-- client sources
server/ <-- server sources
shared/ <-- shared client/server sources
public/ <-- static assets
There are a couple of config files in there: deno.json
and import_map.json
. These are optional (Deno projects can work fine just command line arguments), but they’re nice conveniences.
Traditionally, deno.json
was just an alternate name for tsconfig.json
; Deno didn’t really care about the name because you had to manually point it to whatever config file you were using with a --config
option. With v1.18, Deno started automatically loading a deno.json
if present, making it much more convenient to use. In v1.20.1, deno.json
started supporting tasks, which are a lot like npm scripts. We’ll use the deno.json
file to set compiler options and handle our run scripts.
The other config file, import_map.json
, is just a web-standard import map. It is used in the example app instead of the more traditional deps.ts
for managing imports. Import maps have a couple of advantages over deps.ts
for this use case. They allow import definitions to be shared in all parts of the app, ensuring that the client and server are using the same versions of shared dependencies such as React. They also avoid the potential name clashes that come from managing all external code imports through a single file.
External dependencies
While we could write everything from scratch in Deno, that’s taking things a bit far, so the app uses several external libraries. A couple of packages come from Deno’s standard library at https://deno.land/std, and a few more are Deno-compatible libraries from https://deno.land/x. All of these are referenced through the import_map.json
file. Some dependencies, such as React, don’t publish packages as ES modules. For those cases, several services exist that serve ES modules transpiled from npm packages. The example app mostly uses ESM, a particularly Deno-friendly service, although jspm is used for the jsdom dependency. Regardless of the service being used, from Deno’s perspective, the service is just a domain serving modules. For example, to import React through ESM, a Deno app would just do this:
import React from "https://esm.sh/react@17.0.2"
If you look at import_map.json
, you might notice a line at the bottom that’s not pointing to a specific package:
"https://cdn.esm.sh@types/": "https://cdn.esm.sh/@types/"
This is a sign of both of the problems you can run into using packaging CDNs and of the power that comes with import maps. As of this writing, a couple of the type dependencies in ESM’s build of React have invalid URLs: a “/” has been dropped, giving a host of “cdn.esm.sh@types”. An import map entry provides an easy fix.
Client
The client is a standard React app. A top-level App component contains an Input component and a list of TodoItems. A client/mod.ts
file serves as the entry point for the client, containing the ReactDOM call that renders an App and attaches it to the DOM. (Using “mod.ts” as a generic entry point in a directory or package is a Deno convention; it’s like using “index.js” in Node, although Deno doesn’t treat mod.ts
special.)
ReactDOM.hydrate(
<App initialState={globalThis.__INITIAL_STATE__} />,
document.getElementById("root"),
);
delete globalThis.__INITIAL_STATE__;
The client entry point is hydrating an existing UI (one rendered on the server), so it needs to instantiate the App component with the same data used during server-side rendering. That data will be rendered onto the page in a global __INTIAL_STATE__
variable by the server. The client will use that for initialization, then delete the global to allow it to be garbage collected.
Relatively little code is explicitly shared between the client and server, just the Todo type defined in shared/types.ts
. However, the entire client app is implicitly shared with the server since the server directly executes the client code to perform server-side rendering.
Server
The server uses oak for the HTTP interface and deno-sqlite for the database. In keeping with the “Deno for everything” theme, deno-sqlite doesn’t bind to or require an external sqlite library — it uses a version of SQLite compiled to WebAssembly that can be used directly by Deno’s runtime.
Database
The database, defined in server/database.ts
, is very simple since we just need to load and store todo items. It uses the Todo type from shared/types.ts
, and imports the root DB class from SQLite. This app is simple enough that we don’t need to bother with an ORM — the database module uses simple SQL queries to create and update the database.
Router
Like the database, the router is very simple. It just needs to serve assets and allow interaction with the todos list. In server/routes.tsx
:
import { Router } from "oak";
export type RouterConfig = { client: string, styles: string, db: Database };
export function createRouter(config: RouterConfig): Router {
const { client, styles, db } = config;
const router = new Router();
router.get("/client.js", async ({ res }) => { ... });
router.get("/styles.css", async ({ req, res }) => { ... });
router.get("/todos", async ({ res }) => { ... });
router.post("/todos", async ({ req, res }) => { ... });
router.patch("/todos/:id", async ({ params, req, res }) => { ... });
router.delete("/todos/:id", ({ params, res }) => { ... });
router.get("/livereload/:id", (ctx) => { ... });
router.get("/", ({ res }) => {
const initialState = { todos: db.getTodos() };
const renderedApp = ReactDOMServer.renderToString(
<App initialState={initialState} />,
);
const globalSate = `globalThis.__INITIAL_STATE__ = ${
JSON.stringify(initialState)
}`);
const liveReload = config.devMode ? liveReloadSnippet : '';
res.type = "text/html";
res.body = `<!DOCTYPE html>
<html lang="en">
<head>
<title>Todos</title>
<link rel="stylesheet" href="/styles.css">
<script type="module" async src="/client.js"></script>
${liveReload}
</head>
<body>
<div id="root">${renderedApp}</div>
<script>${globalState}</script>
</body>
</html>`;
};
return router;
}
Note that the router config takes client
and styles
strings. I was stretching the truth a bit when I said there’s no build step; the app will build the JS code and styles, but it will do it on the fly when the server is started. The HTML returned from the root route will have links to “/client.js” and “/styles.css”, and the “/client.js” and “/styles.css” routes will serve the dynamically built resources.
Server side rendering is implemented in the root route handler. It renders the initial app view using the current set of todos and includes that in the page HTML. It also stores a stringified version of the todos list in a global variable for the client app to consume when it initially renders.
Server
The main server code is in server/mod.ts
. It handles building the client and styles, and it exports a function for starting an HTTP server.
export type ServerConfig = { port?: number devMode?: boolean };
const __filename = new URL(import.meta.url).pathname;
const __dirname = path.dirname(__filename);
// read and concatenate the project CSS files and return the result
async function buildStyles(): Promise<string> { ... }
async function buildClient(config?: ServerConfig): Promise<string> {
const emitOptions: Deno.EmitOptions = {
bundle: "module",
compilerOptions: {
target: "esnext",
importMapPath: path.join(__dirname, "..", "import_map.json"),
lib: ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"],
}
};
if (config?.devMode) {
emitOptions.compilerOptions!.inlineSourceMap = true;
emitOptions.importMapPath =
path.join(__dirname, "..", "import_map_dev.json")
}
const { files, diagnostics } = await Deno.emit(
path.join(clientDir, "mod.tsx"),
emitOptions,
);
if (diagnostics.length > 0) {
console.warn(Deno.formatDiagnostics(diagnostics));
}
return files["deno:///bundle.js"];
}
export async function serve(config?: ServerConfig) {
const [styles, client] = await Promise.all([
buildStyles(),
buildClient(config)
]);
const db = openDb();
const { router, updateStyles } = createRouter({ styles, client, db });
const app = new Application();
// hook up the router
app.use(router.routes());
app.use(router.allowedMethods());
// serve assets in the public directory
app.use(async (ctx) => {
await ctx.send({
root: path.join(__dirname, "..", "public"),
});
});
// start listening
...
}
The most interesting part of the server code is buildClient
, which uses Deno.emit to transpile and bundle the client code. This function will handle transpiling JSX, type checking TS and TSX files, and bundling everything into a single module. It supports quite a few TypeScript compiler options, many more than the standard Deno runtime does because the goal of this function is simply to build and output JavaScript rather than to execute code in the Deno runtime. If there are any build issues, Deno provides a very helpful formatDiagnostics
function that can be used to format diagnostics for display.
A devMode
config option affects how the client is built. If devMode
is true, the bundle will be built with inline source maps and an alternate import map. The development import map is almost identical to the default one, except that it points to development versions of the React dependencies. Both of these changes will significantly increase bundle size but are very helpful during development.
The code finds the client files relative to the location of server/mod.ts
. A Node script would use __dirname
for this purpose. Deno doesn’t provide __dirname
, but it does provide the current script’s URL on import.meta.url
, which is enough for us to create a __dirname
equivalent.
Tests
Applications should ideally have tests, and a full stack app will need tests for both the client and server sides. Deno’s integrated test runner is fast, has a simple API, and provides a good set of basic tools for writing tests. The example app includes several tests for both the client and server code using Deno’s test runner and a few support libraries such as @testing-library/react.
Like the rest of Deno, the testing system is fairly flexible, although its default options encourage writing tests alongside application code. For example, tests for client/App.tsx
are in client/App.test.tsx
. Deno’s testing API is fairly simple. A Deno.test function is used to register tests. The testing/asserts.ts
package in Deno’s standard library contains a variety of test assertions. Deno doesn’t have some conveniences like beforeEach
or teardown
; instead, it provides a lower-level tool in the form of steps. A step is a lot like a nested test within a test and can be used to implement setup and teardown actions. For example:
test("adds a todo", testConfig, async ({ step }) => {
let resetFetch: () => void;
await step('test', async () => {
resetDom();
resetFetch = mockFetch({ ... });
const { findByRole, getAllByTestId } = render(<App />);
assertThrows(() => getAllByTestId("todo-item"), Error, 'Unable to');
userEvent.type(await findByRole("textbox"), "Clean house{enter}");
await waitFor(() => {
const items = getAllByTestId("todo-item-label");
assertEquals(items.length, 1);
assertEquals(items[0].textContent, "Clean house");
});
});
await step('cleanup', () => resetFetch());
}
In the test above, first the “test” step runs, which sets up and does the actual test. Afterward, the “cleanup” step runs. All steps will run even if one fails, which is why they work well for cleanup. Deno’s testing system is also capable of collecting coverage data by passing a coverage option that takes the name of a directory to store coverage data into:
$ deno test --coverage=coverage_data
Deno also provides an integrated coverage viewer. Running
$ deno coverage coverage_data
will display a pretty-printed coverage report to stdout. Deno can also create an lcov coverage report from collected coverage data, allowing external tools to work with the data.
One difference between testing with Deno versus a more popular framework like Jest is that you may have to handle more parts of the testing process yourself. For example, when using @testing-library/react with Jest, a basic DOM environment will be pre-configured. In the example app, the tests have to manually set up the DOM environment. In the test above, the first step is a call to resetDom()
, which is a utility function that creates a new JSDOM document and installs it globally.
Developing
A simple script can let new developers get started with a Deno project without having to install anything. Since Deno is distributed as a single self-contained executable, it’s easy for a wrapper script, like the deno.sh
script in the example app, to download and use a local copy of deno if one isn’t available on the system. No separate tool installation or system permissions are required.
Deno also has tools that can make development more efficient. Its run command has a watch mode, enabled with a --watch
option, that will restart a running app if changes are detected. By default this command will watch all source files in the import graph of the script being run, but it can also watch arbitrary files and directories. If a dev server is run with type checking disabled (via the --no-check
option), app restarts are very quick.
The --watch
option will only reload the server, not the client. To get something closer to live-reloading, you can use Deno’s built-in WebSocket support. In server/routes.ts
, the root route inserts a snippet of live reload code into the client, and a “/livereload” endpoint handles websocket connections. Whenever the server restarts, the client will be disconnected and will attempt to reconnect. As soon as it does, the server tells it to reload, ensuring the client is running the most recent code.
This will work for styles, but Deno’s file watching API allows for something more efficient. The server entry point in the example app, server/mod.ts
, sets up a watcher for the style files. If style changes are detected, the watcher calls an updateStyles
function returned by createRouter
. This function will update the cached styles and notify any connected clients to reload them.
Deploying
Applications that will be widely available should probably be deployed to an efficient cloud hosting service. Deno makes this very easy using Deno Deploy.
Simple applications may also be deployed in a more ad-hoc fashion, and Deno has tools to make this easy as well. One option for deploying an application is to use a wrapper script like the one mentioned in the previous section. Clone or update the application repo on a server and simply run a start task with the wrapper.
$ ./deno.sh task start
Another option for sharing an application is the “compile” command, which will create a fully self-contained executable. Compile wraps the deno executable, permissions arguments, and all the app code into a single executable binary. The compile task for the example app looks like:
$ deno compile --unstable --allow-read --allow-write --allow-net --no-check
--import-map=import_map.json mod.ts
The resulting executable can be given to another user or copied to a remote system and run directly — no other programs or libraries are needed. A --target
option allows building for particular target architectures (e.g., x86 Windows).
Conclusion
As you can see, Deno has a lot of functionality out-of-the-box. The built-in tooling may not be the best choice for every use case, but with it, Deno has everything you need to create a performant, self-contained web application.