Zero to Production: Deploy a Full-Stack App with SST and AWS in 60 Minutes

In 2025, deploying production-ready applications shouldn’t require weeks of DevOps work. Today, I’ll show you how to build and deploy a full-stack serverless application in just 60 minutes using SST (Serverless Stack) v3, the framework that’s revolutionizing how we build on AWS.

What We’re Building

We’ll create a real-time collaborative todo application with:

  • Backend: Serverless API with Lambda and API Gateway
  • Database: DynamoDB for scalable data storage
  • Authentication: AWS Cognito for secure user management
  • Frontend: React app with real-time updates
  • Hosting: S3 and CloudFront for global distribution

The best part? Everything is type-safe, from infrastructure to API calls.

Why SST in 2025?

SST has become the go-to framework for serverless development because it:

  • Provides a fantastic local development experience with Live Lambda
  • Offers type-safe infrastructure as code
  • Includes built-in best practices for security and performance
  • Deploys with a single command

Prerequisites (5 minutes)

Before we start, ensure you have:

1
2
3
4
5
6
7
8
# Check Node.js version (18+ required)
node --version

# Install AWS CLI and configure credentials
aws configure

# Install SST CLI globally
npm install -g sst

You’ll also need:

  • An AWS account (free tier works fine)
  • Basic knowledge of React and TypeScript
  • A code editor (VS Code recommended)

Step 1: Project Setup (10 minutes)

Let’s create our project:

1
2
3
4
5
6
7
# Create a new SST project
npx create-sst@latest my-todo-app
cd my-todo-app

# Select the following options:
# ✓ Template: TypeScript starter
# ✓ Package manager: npm

Your project structure should look like this:

1
2
3
4
5
6
my-todo-app/
├── sst.config.ts        # SST configuration
├── packages/
│   ├── functions/       # Lambda functions
│   └── web/            # React frontend
└── stacks/             # Infrastructure definitions

Let’s examine the key configuration file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// sst.config.ts
import { SSTConfig } from "sst";
import { MyStack } from "./stacks/MyStack";

export default {
  config(_input) {
    return {
      name: "my-todo-app",
      region: "us-east-1",
    };
  },
  stacks(app) {
    app.stack(MyStack);
  }
} satisfies SSTConfig;

Step 2: Building the Backend (20 minutes)

Define the Infrastructure

First, let’s create our DynamoDB table and API:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// stacks/MyStack.ts
import { StackContext, Table, Api, Cognito, StaticSite } from "sst/constructs";

export function MyStack({ stack }: StackContext) {
  // Create DynamoDB table for todos
  const table = new Table(stack, "Todos", {
    fields: {
      userId: "string",
      todoId: "string",
      createdAt: "number",
    },
    primaryIndex: { partitionKey: "userId", sortKey: "todoId" },
  });

  // Create Cognito User Pool for authentication
  const auth = new Cognito(stack, "Auth", {
    login: ["email"],
  });

  // Create the API
  const api = new Api(stack, "Api", {
    authorizers: {
      jwt: {
        type: "user_pool",
        userPool: {
          id: auth.userPoolId,
          clientIds: [auth.userPoolClientId],
        },
      },
    },
    defaults: {
      authorizer: "jwt",
      function: {
        bind: [table],
        environment: {
          TABLE_NAME: table.tableName,
        },
      },
    },
    routes: {
      "GET /todos": "packages/functions/src/list.handler",
      "POST /todos": "packages/functions/src/create.handler",
      "PUT /todos/{id}": "packages/functions/src/update.handler",
      "DELETE /todos/{id}": "packages/functions/src/delete.handler",
    },
  });

  // We'll add the frontend later
  stack.addOutputs({
    ApiEndpoint: api.url,
    UserPoolId: auth.userPoolId,
    UserPoolClientId: auth.userPoolClientId,
  });
}

Create Lambda Functions

Now let’s implement our API handlers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// packages/functions/src/create.ts
import { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";
import { randomUUID } from "crypto";

const client = new DynamoDBClient({});
const dynamoDb = DynamoDBDocumentClient.from(client);

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const data = JSON.parse(event.body || "{}");
  const userId = event.requestContext.authorizer?.jwt.claims.sub;

  const params = {
    TableName: process.env.TABLE_NAME,
    Item: {
      userId,
      todoId: randomUUID(),
      text: data.text,
      completed: false,
      createdAt: Date.now(),
    },
  };

  await dynamoDb.send(new PutCommand(params));

  return {
    statusCode: 201,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(params.Item),
  };
};
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// packages/functions/src/list.ts
import { APIGatewayProxyHandlerV2 } from "aws-lambda";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const dynamoDb = DynamoDBDocumentClient.from(client);

export const handler: APIGatewayProxyHandlerV2 = async (event) => {
  const userId = event.requestContext.authorizer?.jwt.claims.sub;

  const params = {
    TableName: process.env.TABLE_NAME,
    KeyConditionExpression: "userId = :userId",
    ExpressionAttributeValues: {
      ":userId": userId,
    },
  };

  const result = await dynamoDb.send(new QueryCommand(params));

  return {
    statusCode: 200,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(result.Items),
  };
};

Step 3: Building the Frontend (20 minutes)

Setup React with Vite

1
2
3
4
# Navigate to web package
cd packages/web
npm create vite@latest . -- --template react-ts
npm install @aws-amplify/ui-react aws-amplify

Configure Amplify for Authentication

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// packages/web/src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { Amplify } from 'aws-amplify'
import { Authenticator } from '@aws-amplify/ui-react'
import '@aws-amplify/ui-react/styles.css'
import App from './App'
import './index.css'

// These will be replaced by SST
Amplify.configure({
  Auth: {
    region: import.meta.env.VITE_REGION,
    userPoolId: import.meta.env.VITE_USER_POOL_ID,
    userPoolWebClientId: import.meta.env.VITE_USER_POOL_CLIENT_ID,
  },
  API: {
    endpoints: [{
      name: 'api',
      endpoint: import.meta.env.VITE_API_URL,
      custom_header: async () => {
        return { Authorization: `Bearer ${(await Auth.currentSession()).getIdToken().getJwtToken()}` }
      }
    }]
  }
})

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <Authenticator>
      <App />
    </Authenticator>
  </React.StrictMode>,
)

Create the Todo App Component

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// packages/web/src/App.tsx
import { useState, useEffect } from 'react'
import { API } from 'aws-amplify'
import './App.css'

interface Todo {
  todoId: string
  text: string
  completed: boolean
  createdAt: number
}

function App() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [newTodo, setNewTodo] = useState('')
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchTodos()
  }, [])

  const fetchTodos = async () => {
    try {
      const response = await API.get('api', '/todos', {})
      setTodos(response)
      setLoading(false)
    } catch (error) {
      console.error('Error fetching todos:', error)
      setLoading(false)
    }
  }

  const createTodo = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!newTodo.trim()) return

    try {
      const response = await API.post('api', '/todos', {
        body: { text: newTodo }
      })
      setTodos([...todos, response])
      setNewTodo('')
    } catch (error) {
      console.error('Error creating todo:', error)
    }
  }

  const toggleTodo = async (todo: Todo) => {
    try {
      await API.put('api', `/todos/${todo.todoId}`, {
        body: { completed: !todo.completed }
      })
      setTodos(todos.map(t => 
        t.todoId === todo.todoId 
          ? { ...t, completed: !t.completed }
          : t
      ))
    } catch (error) {
      console.error('Error updating todo:', error)
    }
  }

  const deleteTodo = async (todoId: string) => {
    try {
      await API.del('api', `/todos/${todoId}`, {})
      setTodos(todos.filter(t => t.todoId !== todoId))
    } catch (error) {
      console.error('Error deleting todo:', error)
    }
  }

  if (loading) return <div>Loading...</div>

  return (
    <div className="app">
      <h1>My Todo App</h1>
      
      <form onSubmit={createTodo} className="todo-form">
        <input
          type="text"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="Add a new todo..."
          className="todo-input"
        />
        <button type="submit" className="add-button">Add</button>
      </form>

      <ul className="todo-list">
        {todos.map(todo => (
          <li key={todo.todoId} className="todo-item">
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo)}
            />
            <span className={todo.completed ? 'completed' : ''}>
              {todo.text}
            </span>
            <button
              onClick={() => deleteTodo(todo.todoId)}
              className="delete-button"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default App

Update Stack to Include Frontend

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// stacks/MyStack.ts (add this to the existing file)
const site = new StaticSite(stack, "Site", {
  path: "packages/web",
  buildCommand: "npm run build",
  buildOutput: "dist",
  environment: {
    VITE_API_URL: api.url,
    VITE_REGION: stack.region,
    VITE_USER_POOL_ID: auth.userPoolId,
    VITE_USER_POOL_CLIENT_ID: auth.userPoolClientId,
  },
});

stack.addOutputs({
  SiteUrl: site.url,
});

Step 4: Local Development (5 minutes)

SST provides an amazing local development experience:

1
2
# From the project root
npm run dev

This starts:

  • Live Lambda development (changes deploy instantly)
  • Local DynamoDB simulation
  • Hot reload for your React app

Visit the URLs provided in the terminal to see your app running locally!

Step 5: Deployment (5 minutes)

Deploying to production is incredibly simple:

1
2
# Deploy to your AWS account
npx sst deploy --stage prod

SST will:

  1. Build and optimize your Lambda functions
  2. Create all AWS resources (DynamoDB, Cognito, API Gateway)
  3. Build and deploy your React app to S3/CloudFront
  4. Output all the URLs and configuration

Step 6: Post-Deployment Setup (5 minutes)

After deployment, you’ll see outputs like:

1
2
3
4
5
6
✓  Deployed:
   MyStack
   ApiEndpoint: https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com
   UserPoolId: us-east-1_xxxxxxxxx
   UserPoolClientId: xxxxxxxxxxxxxxxxxxxxxxxxxx
   SiteUrl: https://xxxxxxxxxx.cloudfront.net

Visit the SiteUrl to see your live application!

Cost Analysis

Running this application costs approximately:

  • Development: $0 (free tier covers everything)
  • Small scale (1K users): ~$5/month
  • Medium scale (10K users): ~$50/month

The serverless model means you only pay for what you use!

Next Steps

You now have a production-ready application! Here are some enhancements to consider:

  1. Add WebSocket support for real-time collaboration
  2. Implement DynamoDB Streams for event-driven updates
  3. Add CloudWatch dashboards for monitoring
  4. Set up CI/CD with GitHub Actions
  5. Enable AWS WAF for additional security

Key Takeaways

  • SST makes serverless development accessible and enjoyable
  • Type-safe infrastructure prevents runtime errors
  • Local development experience rivals traditional apps
  • Production deployment is just one command away

Conclusion

In just 60 minutes, we’ve built and deployed a full-stack serverless application that’s:

  • Scalable to millions of users
  • Secured with AWS Cognito
  • Globally distributed via CloudFront
  • Cost-effective with pay-per-use pricing

The serverless revolution isn’t coming – it’s here. With tools like SST, there’s never been a better time to build on AWS.


Have questions or run into issues? Drop a comment below or reach out on Twitter. Happy building!

Resources