AbsoluteJS

Quickstart

Build a blog with Google OAuth, a database, typed props, and a type-safe API client, all in one codebase.

This guide walks through building a real app. By the end you'll have Google authentication, a users table, server-rendered pages with typed props, a protected dashboard, a validated JSON API, and a client that's fully typed against your server.

Auth

Google OAuth with session management

SSR + DB

Server-rendered pages with Drizzle types

API + Client

Validated endpoints with Eden Treaty

#1. Scaffold the Project

BASH
bun create absolutejs my-blog --react --db postgresql --orm drizzle --auth abs --abs-provider google --tailwind --install --skip
cd my-blog

This sets up a React project with PostgreSQL, Drizzle ORM, Google OAuth, and Tailwind dependencies installed, ready to go. The --skip flag uses defaults for everything else. See all CLI options.

#2. Set Up Environment Variables

Add your database URL and Google OAuth credentials. You can get Google credentials from the Google Cloud Console:

BASH
# .env
DATABASE_URL=postgresql://user:pass@localhost:5432/myapp
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

#3. Define Your Schema

Your database schema is the single source of truth for types. Drizzle infers User and Post directly from the table definitions : no codegen, no drift:

TS
// db/schema.ts
import { pgTable, serial, varchar, text, timestamp } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  authSub: varchar('auth_sub', { length: 255 }).notNull().unique(),
  name: varchar('name', { length: 255 }).notNull(),
  email: varchar('email', { length: 255 }).notNull(),
  createdAt: timestamp('created_at').defaultNow()
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: text('content').notNull(),
  authorId: serial('author_id').references(() => users.id),
  createdAt: timestamp('created_at').defaultNow()
});

// Types are inferred directly from the table : no codegen
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;

#4. Configure the Build

Tell AbsoluteJS where your frontend code lives:

TS
// absolute.config.ts
import { defineConfig } from '@absolutejs/absolute';

export default defineConfig({
  reactDirectory: './src/frontend',
  tailwind: {
    input: './src/styles/globals.css',
    output: './build/styles.css'
  }
});

#5. Build the Pages

A public home page that shows posts and a login link, and a protected dashboard for authenticated users. Props are typed against your schema; change a column and TypeScript shows every page that needs updating:

TSX
// src/frontend/pages/Home.tsx
import type { Post, User } from '../../../db/schema';

type HomeProps = {
  posts: Post[];
  user: User | null;
};

export const Home = ({ posts, user }: HomeProps) => (
  <html lang="en">
    <head>
      <meta charSet="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <title>My Blog</title>
      <link rel="stylesheet" href="/styles.css" />
    </head>
    <body>
      <nav>
        {user ? (
          <>
            <span>Welcome, {user.name}</span>
            <a href="/dashboard">Dashboard</a>
          </>
        ) : (
          <a href="/oauth2/google/authorization">Sign in with Google</a>
        )}
      </nav>
      <h1>Posts</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.content.slice(0, 150)}...</p>
        </article>
      ))}
    </body>
  </html>
);

The dashboard receives a User, guaranteed by the protectRoute guard on the server:

TSX
// src/frontend/pages/Dashboard.tsx
import type { User } from '../../../db/schema';

type DashboardProps = {
  user: User;
};

export const Dashboard = ({ user }: DashboardProps) => (
  <html lang="en">
    <head>
      <title>Dashboard | {user.name}</title>
      <link rel="stylesheet" href="/styles.css" />
    </head>
    <body>
      <h1>Dashboard</h1>
      <p>Signed in as {user.email}</p>
      <p>Member since {user.createdAt}</p>
      <a href="/">← Home</a>
    </body>
  </html>
);

#6. Wire Up the Server with Auth

The server brings it all together. One .use(absoluteAuth()) call adds Google OAuth with automatic user creation, session management, and a protectRoute guard. The App type export powers the type-safe client:

TS
// src/backend/server.ts
import { prepare, asset, getEnv, networking } from '@absolutejs/absolute';
import { handleReactPageRequest } from '@absolutejs/absolute/react';
import { absoluteAuth, instantiateUserSession } from '@absolutejs/auth';
import { Elysia, t } from 'elysia';
import { staticPlugin } from '@elysiajs/static';
import { eq } from 'drizzle-orm';
import { db } from '../../db';
import { users, posts, type User, type Post } from '../../db/schema';
import { Home } from '../frontend/pages/Home';
import { Dashboard } from '../frontend/pages/Dashboard';

const { absolutejs, manifest } = await prepare();

const app = new Elysia()
  .use(absolutejs)
  .use(staticPlugin({ assets: './build', prefix: '' }))

  // Auth : one .use() call for Google OAuth with user management
  .use(absoluteAuth<User>({
    providersConfiguration: {
      google: {
        credentials: {
          clientId: getEnv('GOOGLE_CLIENT_ID'),
          clientSecret: getEnv('GOOGLE_CLIENT_SECRET'),
          redirectUri: 'http://localhost:3000/oauth2/callback'
        },
        scope: ['openid', 'profile', 'email']
      }
    },
    onCallbackSuccess: async ({ authProvider, tokenResponse, session, userSessionId }) =>
      instantiateUserSession({
        authProvider,
        tokenResponse,
        session,
        userSessionId,
        getUser: async (userIdentity) =>
          db.query.users.findFirst({
            where: eq(users.authSub, userIdentity.sub)
          }),
        onNewUser: async (userIdentity) => {
          const [newUser] = await db.insert(users).values({
            authSub: userIdentity.sub,
            name: userIdentity.name ?? '',
            email: userIdentity.email ?? ''
          }).returning();
          return newUser;
        }
      })
  }))

  // Public home page
  .get('/', async ({ getStatus }) => {
    const { data: user } = await getStatus();
    const allPosts = await db.query.posts.findMany();
    return handleReactPageRequest({
      Page: Home,
      index: asset(manifest, 'HomeIndex'),
      props: {
        posts: allPosts,
        user
      }
    });
  })

  // Protected dashboard : user is typed as User from your schema
  .get('/dashboard', ({ status, protectRoute }) =>
    protectRoute(
      (user) => handleReactPageRequest({
        Page: Dashboard,
        index: asset(manifest, 'DashboardIndex'),
        props: { user }
      }),
      (error) => status(error.code, error.message)
    )
  )

  // JSON API with validation
  .post('/api/posts', async ({ body }) => {
    const [post] = await db.insert(posts).values(body).returning();
    return post;
  }, {
    body: t.Object({
      title: t.String({ minLength: 1 }),
      content: t.String(),
      authorId: t.Number()
    })
  })

  .use(networking);

export type App = typeof app;

#7. Type-Safe Client with Eden Treaty

Eden Treaty takes the type of your server and gives your client full autocomplete, compile-time route checking, and typed responses, including the auth status endpoint:

TS
// src/frontend/eden/treaty.ts
import { treaty } from '@elysiajs/eden';
import type { App } from '../../backend/server';

const url = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000';
export const api = treaty<App>(url);

Every API call is type-safe:

TS
// Use the treaty anywhere on the client : fully typed
import { api } from '../eden/treaty';

// POST /api/posts : body is type-checked at compile time
const { data: newPost } = await api.api.posts.post({
  title: 'Hello World',
  content: 'My first post',
  authorId: 1
});

// Check auth status : returns your User type or error
const { data: authStatus, error } = await api.oauth2.status.get();
if (!error) {
  console.log('Logged in as:', authStatus.user.name);
}

#8. Run It

BASH
bun run dev

Or use the AbsoluteJS CLI directly:

BASH
absolute dev src/backend/server.ts

Open http://localhost:3000. Click "Sign in with Google" to test the full OAuth flow. AbsoluteJS handles the redirect, callback, token exchange, and session creation automatically. Edit any component and HMR updates it instantly with your form inputs and scroll position preserved.