EmailJS: sending app emails with no server and no database
I wanted HabitFlow to email me a motivation summary without paying for a backend or trusting an online database to stay free forever. EmailJS turned out to be the perfect fit for an app I built just for myself. Here is how it works, and where it falls short.
How do you make an app email you every week when the app has no server and no online database? That is the question I sat with when I built HabitFlow, a habit tracker I made for one user: me. I wanted it to send me a little motivation email, but I did not want to stand up a backend just to push one message into my own inbox. The short answer is EmailJS, and the longer answer is that it fits a standalone personal app almost suspiciously well, as long as you understand the one thing it cannot do.
I should say up front why HabitFlow is shaped the way it is. I did not build it to ship to an app store or to sign up users. I built it for myself, to keep on my phone, and I wanted it to just work for years without me worrying about a service deciding to lock me out or capping a free tier down to one or two projects. I had been bitten by that before. So HabitFlow keeps all its data on the device, in the browser, and has no online database at all. That single choice is what makes the email story interesting, because once you have no backend, the usual advice for sending email stops applying.
Why not just use a normal email backend?
The standard way to send email from a web app is to keep a secret on a server. You sign up for a provider like Resend or SendGrid, you get an API key, and because that key can send mail as you, you can never put it in the browser where anyone can read it. So you build a tiny server endpoint, the browser calls it, and the server calls the email provider with the secret key. That is the tutorial everyone copies.
Here is the thing that bugged me about that for a personal app. To send myself one email a week, I would be running a server I have to keep alive, and probably an online database to remember who to send to. That is exactly the dependency I was trying to avoid. Free tiers love to change. Today it is generous, next year it caps you at one project, and suddenly a personal app I expected to run for years is asking me to log back in and pick which project survives. For an app whose whole point is low-maintenance permanence, adding a backend just for email felt like inviting the one risk I built the app to dodge.
So what does EmailJS actually do?
EmailJS is a relay that sits between your browser and a real email account you already own. You connect an account once in their dashboard, in my case my Gmail, and you write an email template there. Then your frontend code calls EmailJS with three ids, and EmailJS sends the email through your Gmail for you. There is no server of yours in the middle, because EmailJS is the server.
Think of it like a hotel front desk that mails a postcard for you. You do not need your own post office. You hand the desk a card, they have a stamp account already set up, and the card goes out under their arrangement with the postal service. You just have to be a guest they recognise. In EmailJS terms, being recognised means three values: a service id (which mail account to send through), a template id (which email layout to use), and a public key (which identifies your EmailJS account). Here is the actual send from HabitFlow.
// src/utils/emailUtils.js
import emailjs from '@emailjs/browser'
export async function sendWeeklySummary(email) {
const summary = await buildWeeklySummary() // built from on-device data
const html = buildEmailHtml(summary) // rendered in the browser
const serviceId = import.meta.env.VITE_EMAILJS_SERVICE_ID
const templateId = import.meta.env.VITE_EMAILJS_TEMPLATE_ID
const publicKey = import.meta.env.VITE_EMAILJS_PUBLIC_KEY
await emailjs.send(
serviceId,
templateId,
{
to_email: email,
subject: `HabitFlow Weekly Summary ${summary.weekLabel}`,
html_content: html,
},
{ publicKey }
)
}That is the whole thing. The summary gets built from data sitting in the browser, the HTML gets rendered right there, and emailjs.send hands it off. No fetch call I have to write, no endpoint of mine to deploy, no API secret hiding on a server. The reason this is safe to ship in the browser is the next surprising bit, so let me come back to it.
What did I actually want it to send me?
The point of HabitFlow is to keep me motivated, so the emails are motivation, not data dumps. What I wanted was a rhythm: a nudge every morning and every evening to stay on top of what I am doing, and a fuller weekly summary that tells me how last week actually went. The weekly one is the email I care about most, because it is the one that makes me feel like the week added up to something. It lists each habit, how many days I hit it, my current streak, and a couple of lines on what went well and what to fix.
There was a second, quieter reason I did it this way. I wanted to learn how an app sends automated email out to its users at all, the kind of thing a real product does when it mails everyone their weekly recap. EmailJS sending through Gmail is the smallest honest version of that. It taught me the moving parts, a connected account, a template, a send call, without me having to run a mail server or warm up a sending domain to find out.
Is it safe to put the key in the browser?
This is the part I had to look up twice before it clicked. EmailJS calls its frontend credential a public key on purpose, because they want you to ship it. It goes straight into your built JavaScript, and anyone can open devtools and read it. The first time I saw that, my instinct was that it had to be a mistake. It is not.
The protection is not the key being secret, it is an allowed-origins rule in the dashboard. EmailJS only accepts a send request if it comes from a domain you registered. So a stranger who copies my public key out of my bundle still cannot send mail with it, because their page is not served from my domain. Think of the public key like a building's street address. Knowing the address does not let you into the apartment; the lock on the door is the origin check. For a personal app on a known domain, that is the entire security story, and it is enough.
Is the free tier really enough?
For me, easily. EmailJS gives you 200 emails a month on the free plan. I am one person getting a handful of emails a week, so I am using maybe ten or twelve of those 200. There is no path where I run out, because the app exists only for me. This is the nice part of building something just for yourself: the free tier was designed for hobby usage, and a hobby user of exactly one is about as light as it gets. I never have to think about the cap, which is the whole feeling I was chasing when I decided not to build a backend.
What is the catch?
There is one real limitation, and it is worth being honest about because it is the thing that decides whether this approach is right for you. The email only sends when I open the app. The send is triggered by my browser, so there is no server quietly firing it at 8am while my phone is in my pocket. HabitFlow checks, when I open it, whether it is the right day and whether it already sent this week, and if so it offers to send the summary.
For me that tradeoff is completely fine, and honestly a little fitting. If I did not open my habit tracker all week, I have not earned a cheerful recap of my habits anyway. But it is a hard limit you cannot design around. If you never open the app on the right day, no email goes out. There is no background job watching the clock, because there is no server. That is the deal you accept in exchange for having no backend to maintain.
Things that surprised me
A few things were not what I expected going in:
The public key being public on purpose. I genuinely thought I had misread the docs. Security lives in the origin allow-list, not in hiding the key.
The email template lives in their dashboard, not my repo. I expected to write the email in code, but the layout sits in EmailJS, and my code just fills in the blanks. For a one-person app I ended up rendering the HTML myself and passing it in, which is the opposite of how most people use it.
There was no deploy step for the email at all. Because the send runs in the browser, the email ships in the same bundle as the rest of the app. Changing the template is a normal commit, not a separate function deploy.
It just sends through my own Gmail. The email shows up from me, to me, within a second or two, with no domain setup, no SPF or DKIM records, nothing to verify. For personal volume that is a feature.
The success signal is the email itself. There is no log to check and no dashboard to babysit. If the email arrives, it worked. If it does not, it did not. For one email a week to one person, that is exactly the right amount of observability.
So when is this the right call?
Here is when I think EmailJS is the right tool, and when it is not. It is the right call when you are building something for yourself or a tiny circle, when you have no backend and do not want one, and when it is acceptable that the email fires when someone opens the app rather than on a strict schedule. A personal PWA that lives on your phone and keeps all its data on the device is the perfect fit, which is exactly what HabitFlow is. The no-backend, no-database, no-subscription shape is not a compromise here, it is the point, and EmailJS slots into it without dragging a server back in.
It is the wrong call the moment two things become true. The first is needing the email to fire while the user is not looking, like a payment reminder or a shipping alert that has to go out whether or not anyone opens an app. The second is sending real volume to real users, where you want to own your sending domain, watch deliverability, and not lean on one Gmail account. Cross either line and you want a real server with a real email provider behind it. But for an app I built so I would have one quiet thing that notifies me and just keeps working for years, with nobody able to cap me or lock me out, EmailJS is the smallest tool that does the job, and I would pick it again without hesitating.


