DEV Community

Cover image for Developing a Desktop Application via Rust and NextJS. The Tauri Way.
Vyacheslav Chub for Valor Labs

Posted on

Developing a Desktop Application via Rust and NextJS. The Tauri Way.

Introduction

This article introduces you to a specific but exciting topic and is the sequel to my previous article. If you are keen on Rust integrations, please read Node & Rust: Friendship Forever. The NAPI-rs Way..

I suppose all of you, dear colleagues work or at least know about VSCode. Did you think about the technologies used in VSCode creation? You probably will be surprised if I tell you that VSCode is mainly written on Typescript. But stop... Typescript and Javascript are typical for web or backend-based applications, and VSCode is a standalone UI application. Is it possible to create a Javascript-based standalone UI application? Yes, it is!

If we had discussed this topic a couple of months ago, I would have recommended ElectronJS if you were looking for a way to create a standalone Javascript application. Also, I would provide you the following list of popular Electron-based applications.

  • Microsoft Teams
  • Zoom
  • Slack for Desktop
  • WordPress for Desktop
  • Skype
  • Discord
  • WhatsApp Desktop
  • Postman
  • MongoDB Compass

But the modern IT World does not stand still, and we’ve already had a powerful ElectronJS competitor (it could be its killer in the nearest future, BTW).

Meet Tauri!

If you want to get a brief comparison Tauri with Electron, please, read this article. "Goodbye Electron. Hello Tauri" will also be helpful if you want to understand Tauri pros and some brief technical details.

There is a brief comparison for my impatient readers.

Framework "Frontend" "Backend"
Electron Chromium browser NodeJS
Tauri Native Webview Rust-compiled code

One small note regarding Native Webview meant above. You can find ultimate information on this topic here. In a nutshell, Tauri applications use as HTML renderer Webkit (safari engine) on MacOS, Microsoft Edge WebView2 on Windows, and WebKitGTK on Linux (port of Webkit for Linux). Pay attention to the fact that a Tauri application could behave differently on different platforms according to the information above.

What thoughts would we conclude regarding the table above? Tauri is about performance and simplicity! As a developer who spent several years on Electron-related projects, I'm pretty sure NodeJS could be a bottleneck for the following reasons.

  1. NodeJS is a heavyweight solution with complicated architecture. I mean V8, LibUV with Event Loop, etc.
  2. NodeJS is not a good choice if we need to implement heavy processes like image, data processing, or complicated math calculations.
  3. Inter-Process Communication (Electron IPC) is a way of communication between the "Frontend" and "Backend" in Electron. Its functionality is overcomplicated in coding.
  4. Implementing a multithreading NodeJS-based "Backend" in our Electron-based application could be a nightmare.

Tauri demolishes all of the cons above for the following reasons.

  1. Rust-complied code contains only the needed minimum of functionality (without redundant architectural stuff like V8 or LibUV).
  2. Rust is multithreading-friendly and allows us to get multi-platform implementations.
  3. Rust is full of useful memory-safe mechanisms that prevent developers from making the mistakes, and as a result, we get high-quality predictable code.
  4. Rust-complied code is also more performative than NodeJS-based.

In my opinion, the pros above are critical for the "Backend." That's why according to the reasons above, I found Tauri approach as a perspective.

BTW if you are not a Rust expert and want to know something new about Rust multithreading , please read Multi-threading for Impatient Rust Learners..

The Objective

Of course, Tauri is something new. Despite this, it has good documentation. There are many interesting articles on this topic, and I recommend the following resources reading or watching.

My objective is to provide you with something new to run and test. I created a Tauri application with NextJS & Ant Design-based "Frontend" with some "Backend" calculations that look heavyweight. This application shows us Progress Bar on a screen, and related "progress" data is prepared on the "Backend" (Rust) side.

First Steps

Let's get started!

Create "Frontend" part



npx create-next-app@latest --use-npm --typescript


Enter fullscreen mode Exit fullscreen mode

Answer the following questions...

First screen

Install Tauri dependencies



cd tauri-nextjs-demo
npm i --save-dev @tauri-apps/cli
npm i @tauri-apps/api --save


Enter fullscreen mode Exit fullscreen mode

Updates

Update next.config.js



/** @type {import('next').NextConfig} */

const nextConfig = {
  reactStrictMode: true,
  // Note: This feature is required to use NextJS Image in SSG mode.
  // See https://nextjs.org/docs/messages/export-image-api for different workarounds.
  images: {
    unoptimized: true,
  },
};

module.exports = nextConfig;


Enter fullscreen mode Exit fullscreen mode

Update scripts section in package.json



{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "export": "next export",
    "start": "next start",
    "tauri": "tauri",
    "lint": "next lint"
  }
}


Enter fullscreen mode Exit fullscreen mode

Initialize "Backend" (Tauri) part



npm run tauri init


Enter fullscreen mode Exit fullscreen mode

Answer the following questions...

Second screen

Answer the following questions...

src-tauri folder contains our backend part.

Tauri source

"Backend" functionality

The first bootstrapped version contains a minimal set of functionality. Let's fix it.

Please, open src-tauri/src/main.rs and put the following code.



#![cfg_attr(
  all(not(debug_assertions), target_os = "windows"),
  windows_subsystem = "windows"
)]
use tauri::Window;
use std::{thread, time};

#[derive(Clone, serde::Serialize)]
struct Payload {
    progress: i16,
}

#[tauri::command]
async fn progress_tracker(window: Window){
  let mut progress = 0;
  loop {
      window.emit("PROGRESS", Payload { progress }).unwrap();
      let delay = time::Duration::from_millis(100);
      thread::sleep(delay);
      progress += 1;
      if progress > 100 {
        break;
      }
  }
}

fn main() {
  tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![progress_tracker])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}


Enter fullscreen mode Exit fullscreen mode

Pay attention to the points below.

  1. progress_tracker function should be called from the "Frontend" (Typescript) part.
  2. #[tauri::command] is an attribute that defines the function above as a Javascript-friendly
  3. window: Window parameter should be passed from the "Frontend" side.
  4. The loop inside progress_tracker returns a number every 100ms 100 times.
  5. Pay attention on .invoke_handler(tauri::generate_handler![progress_tracker]) in main function. You must "register" your Frontend-friendly function.

Also, you need to change tauri.identifier value in src-tauri/tauri.conf.json. Say, to com.buchslava.dev in my case.
After that, change build.beforeBuildCommand value to npm run build && npm run export in the file above. It's important because in this example we work with NextJS SSG.

"Frontend" first scratches.

Let's move to our "Frontend" part.

Move to the project's root folder and put the following code into src/pages/index.tsx



import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";

interface ProgressEventPayload {
  progress: number;
}

interface ProgressEventProps {
  payload: ProgressEventPayload;
}

export default function Home() {
  const [busy, setBusy] = useState<boolean>(false);

  useEffect(() => {
    // listen what can Rust part tell us about
    const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
      console.log(e.payload.progress);
    });

    return () => {
      unListen.then((f) => f());
    };
  }, []);

  return (
    <div>
      {!busy && (
        <button
          onClick={() => {
            setBusy(true);
            setTimeout(async () => {
              const { appWindow } = await import("@tauri-apps/api/window");
              // call Rust function, pass the window
              await invoke("progress_tracker", {
                window: appWindow,
              });
              setBusy(false);
            }, 1000);
          }}
        >
          Start Progress
        </button>
      )}
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

It's time to run the example...



npm run tauri dev


Enter fullscreen mode Exit fullscreen mode

Let's open Developer Console (Right click on the screen -> Inspect -> Switch to Console tab) and press "Start Progress" button.

First result

Congrats! We finished the basic Touri stuff and it's time to focus on "Frontend" upgrading.

You can find this solution here.

Add UI part

We need to add a Progress Bar widget to the screen and show the progress on it instead of Console.

First, install Ant Design dependency.



npm i antd --save


Enter fullscreen mode Exit fullscreen mode

Second, remove all content from src/styles/Home.module.css.
Third, put the following content into src/styles/globals.css.



body {
  position: relative;
  width: 100vw;
  height: 100vh;
  font-family: sans-serif;
  overflow-y: hidden;
  display: flex;
  justify-content: center;
  align-items: center;
}


Enter fullscreen mode Exit fullscreen mode

Fourth, put the following code into src/pages/index.tsx instead the existing.



import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { Button, Progress } from "antd";

interface ProgressEventPayload {
  progress: number;
}

interface ProgressEventProps {
  payload: ProgressEventPayload;
}

export default function Home() {
  const [busy, setBusy] = useState<boolean>(false);
  const [progress, setProgress] = useState<number>(0);

  useEffect(() => {
    const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
      setProgress(e.payload.progress);
    });

    return () => {
      unListen.then((f) => f());
    };
  }, []);

  return (
    <div>
      <div style={{ width: "70vw" }}>
        <Progress percent={progress} />
      </div>
      <Button
        type="primary"
        disabled={busy}
        onClick={() => {
          setBusy(true);
          setTimeout(async () => {
            const { appWindow } = await import("@tauri-apps/api/window");
            await invoke("progress_tracker", {
              window: appWindow,
            });
            setBusy(false);
          }, 1000);
        }}
      >
        Start Progress
      </Button>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Let's look at the result...



npm run tauri dev


Enter fullscreen mode Exit fullscreen mode

Result 1

Looks good. But I'm a suspicious guy, and I must be 100% sure that everything between Rust and NextJS parts stays together. I want to add a timer to the "Frontend" screen. As a result, Progress and Timer should work simultaneously without stops.

Let's put the following code into src/pages/index.tsx instead the existing.



import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { Button, Progress } from "antd";

interface ProgressEventPayload {
  progress: number;
}

interface ProgressEventProps {
  payload: ProgressEventPayload;
}

export default function Home() {
  const [busy, setBusy] = useState<boolean>(false);
  const [progress, setProgress] = useState<number>(0);
  const [timeLabel, setTimeLabel] = useState<string>();

  useEffect(() => {
    const timeIntervalId = setInterval(() => {
      setTimeLabel(new Date().toLocaleTimeString());
    }, 1000);
    const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
      setProgress(e.payload.progress);
    });

    return () => {
      clearInterval(timeIntervalId);
      unListen.then((f) => f());
    };
  }, []);

  return (
    <div>
      <div style={{ position: "fixed", top: 20, left: 20 }}>{timeLabel}</div>
      <div style={{ width: "70vw" }}>
        <Progress percent={progress} />
      </div>
      <Button
        type="primary"
        disabled={busy}
        onClick={() => {
          setBusy(true);
          setTimeout(async () => {
            const { appWindow } = await import("@tauri-apps/api/window");
            await invoke("progress_tracker", {
              window: appWindow,
            });
            setBusy(false);
          }, 1000);
        }}
      >
        Start Progress
      </Button>
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

Result 2

It's time to make the last stitch. Till we have progress functionality, we need to stop it somehow. The following modifications allow us to do it.

src-tauri/src/main.rs



#![cfg_attr(
  all(not(debug_assertions), target_os = "windows"),
  windows_subsystem = "windows"
)]
use tauri::Window;
use std::{thread, time};
use std::sync::{Arc, RwLock};

#[derive(Clone, serde::Serialize)]
struct Payload {
    progress: i16,
}

#[tauri::command]
async fn progress_tracker(window: Window){
  // New code
  let stop = Arc::new(RwLock::new(false));
  let stop_clone = Arc::clone(&stop);
  let handler = window.once("STOP", move |_| *stop_clone.write().unwrap() = true);
  // / New code

  let mut progress = 0;
  loop {
      // New code
      if *stop.read().unwrap() {
        break;
      }
      // / New code
      window.emit("PROGRESS", Payload { progress }).unwrap();
      let delay = time::Duration::from_millis(100);
      thread::sleep(delay);
      progress += 1;
      if progress > 100 {
        break;
      }
  }
  window.unlisten(handler); // New code
}

fn main() {
  tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![progress_tracker])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}


Enter fullscreen mode Exit fullscreen mode

src/pages/index.tsx



import { invoke } from "@tauri-apps/api/tauri";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { Button, Progress } from "antd";

interface ProgressEventPayload {
  progress: number;
}

interface ProgressEventProps {
  payload: ProgressEventPayload;
}

export default function Home() {
  const [busy, setBusy] = useState<boolean>(false);
  const [progress, setProgress] = useState<number>(0);
  const [timeLabel, setTimeLabel] = useState<string>();

  useEffect(() => {
    const timeIntervalId = setInterval(() => {
      setTimeLabel(new Date().toLocaleTimeString());
    }, 1000);
    const unListen = listen("PROGRESS", (e: ProgressEventProps) => {
      setProgress(e.payload.progress);
    });

    return () => {
      clearInterval(timeIntervalId);
      unListen.then((f) => f());
    };
  }, []);

  return (
    <div>
      <div style={{ position: "fixed", top: 20, left: 20 }}>{timeLabel}</div>
      <div style={{ width: "70vw" }}>
        <Progress percent={progress} />
      </div>
      <Button
        type="primary"
        disabled={busy}
        onClick={() => {
          setBusy(true);
          setTimeout(async () => {
            const { appWindow } = await import("@tauri-apps/api/window");
            await invoke("progress_tracker", {
              window: appWindow,
            });
            setBusy(false);
          }, 1000);
        }}
      >
        Start Progress
      </Button>
      {/* New code */}
      <Button
        type="primary"
        disabled={!busy}
        onClick={async () => {
          const { appWindow } = await import("@tauri-apps/api/window");
          await appWindow.emit("STOP");
          setProgress(0);
          setBusy(false);
        }}
      >
        Stop Progress
      </Button>
      {/* / New code */}
    </div>
  );
}


Enter fullscreen mode Exit fullscreen mode

result 3

Looks persuasive!

Frontend Backend Communication in Tauri: Implementing Progress Bars and Interrupt Button will tell you more regarding the technique above.

You can find the related source here.

The Fasts

Finally, I want to focus on build stuff. Let's build the app. BTW, I'm working under MacOS. Please, read this one if you want to get more about Tauri build. Let's build!



npm run tauri build


Enter fullscreen mode Exit fullscreen mode

The next information will help you understand where and what you can find regarding the result of the build. You can find your build in /src-tauri/target/release/bundle.

In MacOS, you will find the standalone application /src-tauri/target/release/bundle/macos with the installer-based build /src-tauri/target/release/bundle/dmg.

The most exciting thing here is the 4.7Mb application and 2.3Mb installer. Can you believe it? 4.7Mb of Rust & NextJS & Ant Design!

Image description

Image description

Do you want to compare Tauri's result with Electron's one???

Honestly, when I got this result, my memories from my past returned. I remember 20mb hard disks and IBM PC XT.

IBM PC XT

I also thought about the following. Amazing! I can put an application from 2023 to my PC from 1990. Sounds like a time machine!


PS: Thanks to Eduardo Speroni for helpful notes that improve the article.

Top comments (6)

Collapse
 
cjsmocjsmo profile image
Charlie J Smotherman

I have been researching tauri for one of my projects, I found this very useful, thanks!

Aaaah yes, the 90's, or was it the 80's, IIRC css was the hot new toy that you needed to add to your "links" page so it had 16bit colors instead of 8bit. Today's web development looks nothing like it did back then. Thanks for the stroll down memory lane.

happy coding everyone

Collapse
 
talr98 profile image
Tal Rofe

Thanks for the article.
Why would you use NextJS as your application frontend code? There is no reason at all. Well.. only if you super liked the folder mechanism?

I rather use React & Vite

Collapse
 
gwendalf profile image
gwendalF

Tauri is really nice. Just not sure there is pros of using next vs React. Especially since it's a native webview so I'm less concerned about JS download speed.

Collapse
 
buchslava profile image
Vyacheslav Chub

It seems there is only one way to use React in Tauri. I'm talking about NextJS & SSG.

To make Next.js work with Tauri we are going to use the SSG mode since it generates only static files that can be included in the final binary. (more info here: https://tauri.app/v1/guides/getting-started/setup/next-js)
Enter fullscreen mode Exit fullscreen mode

I didn't find pure React support by default.

Collapse
 
gwendalf profile image
gwendalF

Image description

That's the point of my comment since it use SSG I don't see the benefit of using NextJS over pure React, with Vite for example. Tauri only need a HTML file, in this file you put whateverJS you want, be React/Svelte/Vue/Solid....

Thread Thread
 
buchslava profile image
Vyacheslav Chub

Oh, my bad. Thank you for sharing. Right, Next JS & SSG would look redundant.