plainweb

plainweb is a framework combining HTMX, SQLite and TypeScript for less complexity and more joy ๐ŸŽ‰
2573 sparks

npx create-plainweb
  • app/
    • โ”œโ”€โ”€ routes/
      • โ”‚ โ””โ”€โ”€
    • โ”œโ”€โ”€ components/
      • โ”‚ โ””โ”€โ”€
    • โ”œโ”€โ”€ services/
      • โ”‚ โ””โ”€โ”€
    • โ”œโ”€โ”€ config/
      • โ”‚ โ”œโ”€โ”€
      • โ”‚ โ”œโ”€โ”€
      • โ”‚ โ””โ”€โ”€
    • โ””โ”€โ”€ cli/
      •   โ””โ”€โ”€

import { zfd } from "zod-form-data";
import { type Handler } from "plainweb";
import { database } from "app/config/database";
import { contacts } from "app/config/schema";
import { createContact } from "app/services/contacts";
import { Form } from "app/components/form";

export const POST: Handler = async ({ req }) => {
  const parsed = zfd 
    .formData({ email: zfd.text().refine((e) => e.includes("@")) })
    .safeParse(req.body);

  if (!parsed.success) { 
    return <Form email={parsed.data.email} error="Invalid email" />;
  }

  await createContact(database, parsed.data.email); 
  return <div>Thanks for subscribing!</div>;
}

export const GET: Handler = async () => {
  return <Form />;
}


export interface FormProps {
  email?: string;
  error?: string;
}

export function Form(props: FormProps) {
  return ( 
    <form hx-post="/signup">
      <input type="email" name="email" value={props.email} />
      {props.error && <span>{props.error}</span>}
      <button>Subscribe</button>
    </form>
  );
}


import { sendMail } from "plainweb";
import { type Database } from "app/config/database";
import { contacts } from "app/config/schema";

export async function createContact(database: Database, email: string) {
   await sendMail({
     from: "sender@example.org",
     to: email,
     subject: "Hey there",
     text: "Thanks for signing up!",
   });
   await database.insert(contacts).values({ email });
}


import { text, sqliteTable, int } from "drizzle-orm/sqlite-core";

export const contacts = sqliteTable("contacts", {
  email: text("email").primaryKey(),
  created: int("created").notNull(),
  doubleOpted: int("double_opted"),
});

export type Contact = typeof contacts.$inferSelect;


import BetterSqlite3Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { env } from "app/config/env";
import * as schema from "./schema";

const connection = new BetterSqlite3Database(env.DB_URL);
connection.pragma("journal_mode = WAL");

export const database = drizzle<typeof schema>(connection, { schema });
export type Database = typeof database;


import dotenv from "dotenv";
import z from "zod";

dotenv.config();

export const envSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.coerce.number().default(3000),
  DB_URL: z.string().default("db.sqlite3")
})

export type Env = z.infer<typeof envSchema>;

export const env: Env = envSchema.parse(process.env);


import { debug, env } from "app/config/env";
import { http } from "app/config/http";

async function serve() {
  await http();
  debug && console.log(`โšก๏ธ http://localhost:${env.PORT}`);
}

void serve();

Key features

Server-side rendering ๐Ÿ–ฅ๏ธ

Compose and render JSX on the server. Fully type-safe.

No bundle.js ๐Ÿš€

Sprinkle HTMX and Alpine.js on top. No frontend build process.

Streaming ๐ŸŒŠ

Stream responses using <Suspense/> without client-side JavaScript.

File-based routing ๐Ÿ“

The file system determines the URL paths. No more need to name your routes.

Simple deployment ๐Ÿ”Œ

A single process to deploy and manage.

Type-safe SQL ๐Ÿ›ก๏ธ

Type-safe SQL query builder that gets out of your way.

Background Tasks โฑ๏ธ

Run persistent tasks in the background, concurrently or in-parallel.

Testable ๐Ÿงช

Test services, components, routes, emails and tasks with ease.

Minimal lock-in ๐Ÿ”“

plainweb = SQLite + drizzle + Node.js + express + zod + nodemailer + HTMX

Docs โ†’

Stay up to date

Receive ~2 updates a month, no spam, unsubscribe anytime.