Using Sqlite with Electron (Electron Forge)
Guide to setup Sqlite with Electron so that it work in both Dev and Production

Now before we more forwards here comes the documentation part of Electron , despite how great Forge is there are some underling “secret sauce” of Electron that needed to be taken into account if one would like to understand what's happening.
Well there are quite a many methods that are worth mentioning but in-order to focus on the title I will choose only what concerns us at the moment other will talk about at a later point Hopefully.
Understanding ASAR (Atomic Shell Archive)
Electron apps use something called an ASAR file. In very simple terms, it’s like Electron’s version of a “bundle.”
If you’ve used CRA, everything eventually gets mounted into:
<div id="root"/>
Now, ASAR is not literally like a DOM root, but the idea is similar in spirit:
Electron takes all your JavaScript, HTML, CSS, and assets and packs them into one archive file called:
app.asar
Think of it as a zip file that Electron can read from very quickly.
When your app is packaged, most of your project ends up inside this ASAR.
Now here is the fun part some libraries such as our better-sqlite3 require native modules so they simply cannot reside in this shell.
In order to use better-sqlite we need to make changes to our forge.config.*
import { join } from "path";
import Database from "better-sqlite3";
import { app } from "electron";
import fs from "fs";
export const getDatabase = (filename: string) => {
// app.isPackaged is property that holds true if your app has been packaged
const isProd = app?.isPackaged || false;
const dbPath = isProd
? join(process.resourcesPath, filename)
: filename;
// Ensure directory exists
const dir = join(dbPath, "..");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
console.log(`Using SQLite database: ${dbPath}`);
console.log(`Production mode: ${isProd}`);
// Open the database
const db = new Database(dbPath);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
return db;
};
// Default DB path (same logic as your original)
const appDataDir = join(
process.env.APPDATA ||
(process.platform === "darwin"
? join(process.env.HOME!, "Library", "Application Support")
: join(process.env.HOME!, ".local", "share")),
"myapp", // main folder for your app
"myapp.db" // SQLite database file
);
// Export a singleton instance
export const db = getDatabase(defaultDbDir);
Next Paste the following in vite.main.config.ts and vite.preload.config.ts
import { defineConfig } from "vite";
export default defineConfig({
build: {
sourcemap: true,
rollupOptions: {
external: ["better-sqlite3"],
},
lib: {
// change the entry based on your structure
entry: "src/main.ts",
formats: ["cjs"],
},
},
});
Next is our forge.config.ts
import type { ForgeConfig } from '@electron-forge/shared-types';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerZIP } from '@electron-forge/maker-zip';
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { VitePlugin } from '@electron-forge/plugin-vite';
import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { FuseV1Options, FuseVersion } from '@electron/fuses';
import path from "node:path"
import { cp, mkdir } from "node:fs/promises";
const config: ForgeConfig = {
packagerConfig: {
asar: {
unpack: "*.{node,dylib}",
//unpack only unpacks files already inside ASAR,
//but native node_modules are not copied automatically during packaging.
unpackDir: "{better-sqlite3}",
},
},
rebuildConfig: {
onlyModules: ["better-sqlite3"],
force: true,
platform: process.platform,
buildFromSource: true,
},
makers: [new MakerSquirrel({}), new MakerZIP({}, ['darwin']), new MakerRpm({}), new MakerDeb({})],
plugins: [
new VitePlugin({
// `build` can specify multiple entry builds, which can be Main process, Preload scripts, Worker process, etc.
// If you are familiar with Vite configuration, it will look really familiar.
build: [
{
// `entry` is just an alias for `build.lib.entry` in the corresponding file of `config`.
entry: 'src/main.ts',
config: 'vite.main.config.ts',
target: 'main',
},
{
entry: 'src/preload.ts',
config: 'vite.preload.config.ts',
target: 'preload',
},
],
renderer: [
{
name: 'main_window',
config: 'vite.renderer.config.ts',
},
],
}),
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
hooks: {
async packageAfterCopy(_forgeConfig, buildPath) {
const requiredNativePackages = [
"better-sqlite3",
];
const sourceNodeModulesPath = path.resolve(__dirname, "node_modules");
const destNodeModulesPath = path.resolve(buildPath, "node_modules");
await Promise.all(
requiredNativePackages.map(async (packageName) => {
const sourcePath = path.join(sourceNodeModulesPath, packageName);
const destPath = path.join(destNodeModulesPath, packageName);
await mkdir(path.dirname(destPath), { recursive: true });
await cp(sourcePath, destPath, {
recursive: true,
preserveTimestamps: true,
});
}),
);
},
},
};
export default config;
BreakDown
async packageAfterCopy(_forgeConfig, buildPath) {
packageAfterCopy is a hook that runs after Electron Forge copies your app’s files to the build directory, but before packaging them into the final installer or app.asar
const requiredNativePackages = [
"better-sqlite3",
];
Native modules like better-sqlite3 contain compiled binaries, which sometimes are not automatically included in the packaged app.
const sourceNodeModulesPath = path.resolve(__dirname, "node_modules");
const destNodeModulesPath = path.resolve(buildPath, "node_modules");
await Promise.all(
requiredNativePackages.map(async (packageName) => {
const sourcePath = path.join(sourceNodeModulesPath, packageName);
const destPath = path.join(destNodeModulesPath, packageName);
await mkdir(path.dirname(destPath), { recursive: true });
await cp(sourcePath, destPath, {
recursive: true,
preserveTimestamps: true,
});
}),
);
Loops over
requiredNativePackages(better-sqlite3here).For each package:
Builds source and destination paths.
Ensures the destination directory exists (
mkdir(..., { recursive: true })).Copies the package recursively from source to destination, preserving timestamps.
Why this is needed
Electron packages apps differently than a regular Node.js app. (https://stackoverflow.com/a/2456882)
Some native modules (with compiled binaries) don’t get packaged automatically.
This hook ensures that
better-sqlite3is included in the packaged app, so your app can run properly.




