Build an email newsletter with Claude Code
In this video we build a newsletter blog with Claude Code on Elements. The blog is public, readers sign up with an email and password, and every Friday each subscriber gets one email with the posts published since the last issue. The email goes out through Amazon SES, which costs under $1 a month at 2,000 subscribers. Everything else is built in Elements, the way we want it.
The newsletter is one part of a business application. On Elements, the same application can hold the CRM, invoicing, payments and products, with every customer record in one Postgres database, in place of a separate SaaS subscription for each. If a newsletter is all you send, a SaaS product made for newsletters probably suits you better. If you want one application for your business that you control, and that looks and works the way you want, this is how we build it.
Setup
We create a new app called blog in VS Code, with Claude Code in the lower left and a terminal in the upper right. Elements does not depend on the editor or the agent: the terminal, VS Code and the Claude Code desktop app all work, and so does any AI agent. Once Elements is installed, the editor and the agent know about it.
In the terminal we start Elements, a project server that runs while you work:
Terminalelements start
The browser opens on the starter app. The state in the terminal reads green when the app builds and red when there is an error, whether the agent made it or we did. The agent here is Claude Code with Opus 5.5.
The prompt
Agent PromptI want to build a blog that has a weekly newsletter that goes out to users. The blog is available to anyone to see, but the newsletter only goes to subscribed users, users that have registered with an email and password. The newsletter should go out every Friday, and I only want to show users the new articles that have been created since the last newsletter went out. Don't send them a repeat set of articles, and if nothing was written, don't send them a blank email.
Claude Code builds the app
While the agent works, the state turns red now and then. The project server answers the agent in microseconds with the error and how to fix it, the agent fixes it, and the state goes back to green.
The agent finished in about 5 to 10 minutes, with 29 passing tests, and clicked through the flow in headless Chrome to check it. Its summary lists what it built: the public blog, accounts, a newsletter every Friday at 9am with no repeats and no blank emails, and an admin page where only administrators write posts. It also gives us demo accounts to sign in with and a page that previews the newsletter.
Trying the app
The preview shows the newsletter as a subscriber gets it: the two demo posts, then an unsubscribe link at the bottom. We open a post, go back, and create an account with an email and password. Accounts are part of the app and live in its Postgres database, with no outside service for authentication.
Themes
The app uses the editorial theme from Elements UI, and a theme is one import in the app's stylesheet:
app/shared/styles/page.css@import "@elements/ui"; @import "@elements/ui/themes/editorial"; @import "./vars.css";
We ask Claude Code to show the brutalist theme, then luxury, then playful, and go back to editorial. A designer can also make a theme for you, or you can ask the agent for something different. The themes are at elements.dev/man/ui/themes (opens in a new tab).
What it costs to run
Locally, the newsletter only renders. To send it for real, we ask Claude Code what it would cost to deploy the app and send the email with Amazon SES, for a couple of hundred to a couple of thousand subscribers. Claude Code looks up the prices on the day and comes back with about $26 a month in total:
- About $25 a month to host the app on AWS in Ohio: a small server with a 20 GB disk and a public IP address, and managed Postgres with 20 GB of storage.
- Under $1 a month for Amazon SES at 200 to 2,000 subscribers. SES charges per email sent (pricing (opens in a new tab)).
The server and database prices are on AWS's EC2 (opens in a new tab) and RDS for PostgreSQL (opens in a new tab) pages, and they change, so ask your agent for the current figures. Is $26 a month worth paying? For an application that runs your business, with the newsletter as one part of it, we think so, and that one application can replace several subscriptions.
Sending real email
When you are ready, ask your agent to set up SES. If you have an AWS account, the agent configures SES with the AWS command line. If you do not, it walks you through creating one. The manual's email setup (opens in a new tab) covers the steps.
Deliverability starts with sending through a service like SES rather than a mail server on your laptop. Then the agent adds a few records to your domain, the DKIM signing records and a DMARC record, so receiving servers trust the mail. After that, deliverability holds as long as you are not spamming people, and that includes not letting bots put addresses into your signup form. Ask the agent to design forms that bots cannot fill. No separate deliverability service is needed.
Code tour: database migrations
The agent changes the database through migrations, plain SQL files in app/migrations. Agents write SQL well, so there is no ORM to learn. Save a migration and Elements applies it to the database right away. Edit one and Elements synchronizes the change. Each migration runs in a transaction, so a failure rolls back in full. On a deploy, migrations run against the test database first, and a migration that breaks a test never reaches the app database.
The second migration is seed data. The @migration env=development build tag applies it to the development databases only, which is where the demo accounts come from:
app/migrations/20261110121948-demo-rows.migration.sql/** @migration env=development */ insert into users ( email, passwordHash, role, subscribed ) values ( 'admin@example.com', crypt('password123', genSalt('bf', 12)), 'admin', true );
Templates
Each page lives in app/pages, and its HTML is an .ehtml file: Elements HTML, regular HTML with TypeScript in it. The extension to HTML is small. Constructor attributes in parentheses after the tag name are the values a caller provides, typed like function parameters. The account page takes the account and whether the user is an admin:
app/pages/account/template.ehtml<html lang="en" class="page-account" ( account: Account, isAdmin: boolean = false, )> <body> <SiteLayout {isAdmin}> <h1>Your account</h1> <p class="account-email">Signed in as {account.email}</p> </SiteLayout> </body> </html>
SiteLayout is a shared template the agent factored out for the header, the main column and the footer, so every page reuses it. Templates are symbols, like classes and functions, so go to definition in VS Code jumps from the page to the layout in app/shared/templates/site-layout:
app/shared/templates/site-layout/index.ehtml<SiteLayout ( isAdmin: boolean = false, )> <div class="app"> <header class="site-header"> <a class="site-brand" href="/">{config.site.name}</a> <a e:if={isAdmin} class="link-muted" href="/admin">Admin</a> </header> <main class="site-main"> <slot/> </main> </div> </SiteLayout>
Code this close to plain HTML reads easily for the next agent or the next person on the team, and the build tooling, the framework and the HTML language keep the agent from turning it into a tangle as the app grows. The language is covered at elements.dev/man/html (opens in a new tab).
RPC functions and the security check
TypeScript at the top of an .ehtml file stays on the server. A function tagged @rpc is one the browser can call on the server. Here the subscribe switch on the account page calls setSubscribed, which updates the signed-in user and throws if nobody is signed in:
app/pages/account/template.ehtml/** @rpc */ export function setSubscribed(subscribed: boolean) { session.isLoggedInOrThrow(); sql(` update users set subscribed = ${subscribed} where id = ${session.getOrThrow("userId")} `); }
Remove the @rpc tag, so the browser would run the function itself, and save. The build reports a security error at once: browser code cannot call server code without going through an RPC function. The error shows where the problem is, the call stack and how to fix it, and the agent sees the same error. Put the tag back and the state returns to green. sql() and other server code never reach the browser, so every path from the browser to the database goes through an RPC function you can check with the session. The manual has the details at elements.dev/man/rpc (opens in a new tab).
Jobs
Jobs are classes that put work on a queue for workers to run outside the request, with retries when they fail. Elements comes with jobs and cron, and the queue and workers are part of every deploy, with no extra subscription. The agent schedules this job every Friday at 9am:
app/jobs/send-newsletter.tsexport class SendNewsletterJob extends Job<SendNewsletterJobFields> { run() { let result = createIssue(); if (result.sent) { console.log(`newsletter: ${result.postCount} posts to ${result.recipientCount} subscribers`); return; } console.log(`newsletter: nothing sent (${result.reason})`); } }
Emails
Emails are .ehtml templates too, marked with the @email build tag. The compiler inlines the email's stylesheets into the message, because many email clients do not load external stylesheets. Otherwise an email is regular HTML with constructor attributes, and it renders in the browser for a preview before anyone receives it:
app/emails/newsletter/index.ehtml/** @email */ <html ( posts: IssuePost[], issueDate: Date, unsubscribeUrl: string, )> <body class="email newsletter"> <div e:for={post of posts} class="post"> <h2><a href={`/posts/${post.slug}`}>{post.title}</a></h2> <p e:if={post.excerpt}>{post.excerpt}</p> </div> </body> </html>
The stylesheet it imports is regular CSS. Elements UI provides style rules and templates to use when you want them, with no CSS utility library between you and the CSS.
Tests
Tests sit next to the pages they cover, such as app/pages/account/test.ts. They let the agent add a feature and know it did not break one that already worked. In a new terminal we run:
Terminalelements test
The results come back at once, all passing, because the project server holds the test state and answers in microseconds. The agent asks the same question after every change. On a deploy the tests run again on the server, and if any fail, the deploy stops.
The manual covers all of this at elements.dev/man (opens in a new tab), and questions go in the comments.
4 comments
How do I put the blog live on my own domain?
Where do I actually write and publish posts?
How does it know which posts already went out last Friday?
How do I keep my newsletter out of spam folders?
Add a comment